From 99e325399c19bababc6de1e4ec99a153a5465740 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Sat, 20 Jun 2026 00:52:01 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(linear):=20agent-session=20fetch=20+?= =?UTF-8?q?=20append-only=20guards=20raw=20GraphQL=20(chat@4.31/#151=20?= =?UTF-8?q?=E2=80=94=20L5/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Linear agent-session FETCH/read path (last of the 5-PR Wave D, #151), building on L1–L4 already on main. The Python adapter has no @linear/sdk, so upstream's linear.agentSession(id) + linear.comments({filter}) calls (fetchAgentSessionMessages, index.ts:1771) are ported as raw GraphQL queries over the existing _graphql_query helper, schema-hardened field-by-field against Linear's published schema.graphql. Surface (strictly the read path; the L4 emit methods and the comment-path fetch are left byte-identical): - fetch_messages: FIRST branch dispatches agent-session threads to the new _fetch_agent_session_messages; comment/issue branches unchanged. - _fetch_agent_session_messages: agentSession(id) query (selecting issue { id } + the root comment relation), issueId/root-comment raises, direction-driven children pagination (forward->first, otherwise last), per-comment thread_id (linear:{issue}:c:{comment}:s:{session}) via reused L4 author logic, and next_cursor = endCursor if hasNextPage else None. - edit_message / delete_message: append-only guards raising the exact upstream AdapterError strings for session threads before any network call. - fetch_thread: agentSessionId added to metadata. CRITICAL schema-hardening: AgentSession has NO scalar issueId field in the published schema (only the issue: Issue relation), so the issue id is read off issue { id } — equivalent to upstream's agentSession.issueId ?? thread.issueId. Requesting a non-existent issueId would server-reject the whole query (the L4 blocking-bug class). Nullish (issue.id ?? thread, endCursor ?? undefined) uses is not None, not or. Tests: tests/test_linear_agent_session_fetch.py (20 tests) — happy path, issueId fallback + missing-issueId/missing-root-comment raises, forward-vs- backward (first/last) pagination, next_cursor by hasNextPage, append-only edit/delete raises, and comment-path-unchanged regressions. Each fails on a plausible mutation (forward/backward swap, per-comment-id collapse, cursor- logic flip, dropped guard, dropped issueId fallback) — all six verified. Doc: UPSTREAM_SYNC L5 divergence row + Wave D marked complete. Wave D (#151) is now complete. --- docs/UPSTREAM_SYNC.md | 3 +- src/chat_sdk/adapters/linear/adapter.py | 237 +++++++++- tests/test_linear_agent_session_fetch.py | 573 +++++++++++++++++++++++ 3 files changed, 810 insertions(+), 3 deletions(-) create mode 100644 tests/test_linear_agent_session_fetch.py diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index c6d9c93..45473fe 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -686,6 +686,7 @@ stay explicit instead of being rediscovered in code review. | Teams `graph` defensive shape coercion (vercel/chat#8c71411, chat@4.31) | `to_graph_message` / channel + message readers coerce unexpected Graph payload shapes with `isinstance` guards (`x if isinstance(x, Mapping) else {}`, `value if isinstance(value, list) else []`) before reading fields | Upstream `graph/messages.ts` reads `message.from?.user` / `message.body?.content ?? ""` etc. with optional chaining — a non-object where an object is expected throws at the property access | Benign defensive divergence. Upstream's optional chaining tolerates `null`/`undefined` but throws on a wrong-typed non-null (e.g. a string where an object is expected); our `isinstance` coercion fails closed to an empty mapping/list instead of raising. For well-formed Graph responses the behavior is identical; the divergence only manifests on malformed payloads, where returning an empty-shape result is more resilient than throwing. Mirrors the repo's general "more resilient than throw" stance (cf. the `renderPostable on unknown input` row). | | `ThinkingChunk` opt-in stream-input type (Python-only, default-off; supersedes PR #39) | A **separate, opt-in** dataclass — `ThinkingChunk(type="thinking", content=str)` — surfaces AI-SDK `reasoning`/`reasoning-delta` (and pydantic-ai `part_kind == "thinking"`) parts. **`StreamChunk` is NOT widened**: the canonical union stays `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — byte-identical to upstream's three variants, so a consumer doing an exhaustive `match` over `StreamChunk` sees zero change on upgrade. `ThinkingChunk` is accepted only at the **stream-input/output boundaries** via the public alias `StreamInput = str \| StreamChunk \| ThinkingChunk` (the `Adapter.stream()` protocol signature, `from_full_stream`/`_from_full_stream` returns, `Thread._wrapped_stream`, and each receiving adapter's `stream()` signature). A producer can yield `ThinkingChunk` (opt-in) and the adapters that receive the stream type-check; code that only references `StreamChunk` never touches it. **OPT-IN, default-off**: emitted only when a caller passes `from_full_stream(stream, emit_thinking=True)` or sets the thread-level `emit_thinking=True` config; the internal `_from_full_stream` threads the same flag. With the default (`emit_thinking=False`) the normalized stream is **byte-for-byte identical** to upstream — reasoning parts are dropped and **no** `ThinkingChunk` is produced. Consumption is graceful: `Thread._handle_stream` never accumulates a `ThinkingChunk` into the posted-message text, and every adapter's stream handler skips it (Slack/Teams expose an optional `render_thinking` hook via `chat_sdk.shared.adapter_utils.maybe_render_thinking`; the text-accumulate adapters ignore it structurally). **Streaming-only — never persisted**: `Message` has no `thinking` field, `to_json()` is unchanged, and a round-tripped `Message` is byte-identical, so cross-SDK state (Redis/Postgres shared with the TS SDK) stays compatible. | Upstream `from-full-stream.ts` forwards only `text-delta` + `finish-step`; AI-SDK `reasoning`/`reasoning-delta` parts fall through and are discarded. `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — no reasoning variant and no stream-input alias. Upstream leaves reasoning display to the AI-SDK web UI. | chinchill actively streams agent thinking to Slack/Teams but has to intercept the model stream out-of-band today because the chat-platform SDK has no path for it. This gives the SDK a first-class, opt-in one without changing any default behavior — and crucially **without widening the public `StreamChunk` union**, so consumers referencing it are unaffected. The whole design constraint is that default-off == upstream and `StreamChunk` == upstream: separate opt-in input type, opt-in emit, graceful/skip consume, zero state pollution. Regression coverage: `tests/test_thinking_chunk.py`, `tests/test_from_full_stream.py::TestThinkingOptIn`, `tests/test_types.py::TestThinkingChunk`, plus per-adapter no-crash tests in `tests/test_slack_api.py`, `tests/test_teams_native_streaming.py`, `tests/test_twilio_adapter.py`, `tests/test_messenger_api.py`. | | Linear agent-activity emit: raw GraphQL (chat@4.31 / #151 — L4) | The agent-session EMIT path (`post_message` session branch, `start_typing` session branch, `stream` → `_stream_in_agent_session` with its flush/`task_update`/`plan_update` logic, and `_parse_message_from_agent_activity`) is ported as **raw GraphQL mutations** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema**. Mutation names: `agentActivityCreate(input: AgentActivityCreateInput!)` and `agentSessionUpdate(id: String!, input: AgentSessionUpdateInput!)`. `content` is sent inside the `AgentActivityCreateInput.content` **`JSONObject!`** scalar — so `type`/`body`/`action`/`parameter`/`result` are inline JSON fields, with the **lowercase** `AgentActivityType` enum values `"response"` / `"thought"` / `"error"` / `"action"` (confirmed lowercase, NOT PascalCase). `ephemeral: Boolean` is a sibling of `content` and is **included only when set** (absent — not `false` — on response/thought/error). The `agentActivityCreate` return selection requests only schema-valid fields (`success`, `agentActivity { id agentSession { id } sourceComment { id body parentId createdAt updatedAt url user{…} botActor{…} } }`) — `agentSessionId` is **not** a scalar field on Linear's `AgentActivity` type (the schema exposes only the relation `agentSession: AgentSession!`), so the session id is read off the nested `agentSession { id }` relation; requesting the non-existent scalar would server-reject the whole mutation under GraphQL strict selection validation. `plan` items are `{content, status:"completed"}`. `initialize` additionally captures the viewer's `organization.id` into `_default_organization_id` (mirroring upstream's `defaultOrganizationId`) for the emitted raw message's `organizationId`; on the emit path `organizationId` falls back to `""` when `_default_organization_id` is unset (no per-request installation context is plumbed — a pre-existing adapter-wide divergence from upstream, which throws `AuthenticationError` when no organization is resolvable). | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `createAgentActivity({agentSessionId, content, ephemeral?})` / `updateAgentSession(id, {plan})`; the SDK owns the GraphQL document, the `AgentActivityType` enum, and the `AgentActivityPayload`/`Comment`/`sourceComment` resolution | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Mutation names, the `content: JSONObject!` shape, the lowercase enum casing, and the `plan` item shape are **schema-hardened against the published schema** (`https://linear.app/developers/agent-interaction`, `https://linear.app/developers/graphql`, and the SDK's generated GraphQL documents). **Live-tenant verification pending**: the exact mutation/field names and enum casing are confirmed against the published schema/docs but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a casing/field mismatch (e.g. an enum the schema renders differently at runtime), update the mutation strings here. Faithful-port hazards preserved: `status ?? "Thinking..."` → `is not None` (an empty status stays `""`); `[title, output].filter(Boolean).join("\n")` → `"\n".join(x for x in [title, output] if x)` (drops `None` and `""`); `markdown.slice(...).trim()` uses the JS-`.trim()` whitespace set (`_JS_WHITESPACE`, mirroring `adapters/telegram/rich.py`), not Python's broader `str.strip()`; `if delta or force`; `ephemeral: status != "complete"`; the missing-final-flush bare `throw new Error(...)` → `RuntimeError`. Regression coverage: `tests/test_linear_agent_session_emit.py`. | +| Linear agent-session fetch: raw GraphQL (chat@4.31 / #151 — L5) | The agent-session FETCH/read path (`fetch_messages` session dispatch → `_fetch_agent_session_messages`, plus the append-only guards on `edit_message`/`delete_message` and the `agentSessionId` key in `fetch_thread` metadata) is ported as **raw GraphQL queries** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema** (`linear/packages/sdk/src/schema.graphql` @ master). Two queries: (1) `agentSession(id: String!): AgentSession!` selecting `id`, `issue { id }`, and the nullable `comment { id body parentId createdAt updatedAt url user{…} botActor{…} }` root relation; (2) `comments(filter: CommentFilter, first: Int, last: Int, after: String): CommentConnection!` filtered by `{parent: {id: {eq: rootComment.id}}}` for the children, selecting the same `Comment` sub-fields + `pageInfo { hasNextPage endCursor }`. **CRITICAL schema-hardening: `AgentSession` has NO scalar `issueId` field in the published schema** (it exposes only the `issue: Issue` relation alongside `comment`/`sourceComment`/`id`); upstream's `agentSession.issueId` works because `@linear/sdk`'s model derives it from the serialized object, but in raw GraphQL requesting a non-existent `issueId` field would server-reject the whole query (the L4 blocking-bug class). So the issue id is read off the `issue { id }` relation — equivalent to upstream's `agentSession.issueId ?? thread.issueId`, and the same `issueId ?? issue?.id` fallback upstream itself uses at `index.ts:959`. Pagination is direction-driven (`forward` → `first`, otherwise `last`, default limit 50); `next_cursor = endCursor if hasNextPage else None`. Each of `[rootComment, *children.nodes]` is parsed via the upstream `parseMessageFromComment(comment, issueId, agentSession.id)` semantics — reusing L4's `_raw_message_from_source_comment` (user-vs-`botActor` author resolution) + `_parse_agent_session_message` (the `parseMessage` agent-session branch), so **each message's `thread_id` encodes the comment's OWN id** (`linear:{issueId}:c:{comment.id}:s:{agentSessionId}`, NOT a single fixed thread id) and `is_mention=True`. `edit_message`/`delete_message` raise `AdapterError` with the exact upstream strings ("…append-only and cannot be edited" / "…cannot be deleted") for session threads, before any network call. | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `linear.agentSession(id)` (lazy-resolving `issueId` + the `comment` relation off the SDK model) and `linear.comments({filter, first/last})`; the SDK owns the GraphQL documents and the `Comment`/author resolution. | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Query names, the `agentSession(id)` shape, the root `comments(filter: CommentFilter, first/last/after)` connection, the `CommentFilter.parent → NullableCommentFilter.id → IDComparator.eq` chain, and every selected `AgentSession`/`Comment`/`User`/`ActorBot`/`PageInfo` field were each **verified field-by-field against the published `schema.graphql`** (this is how the absence of a scalar `AgentSession.issueId` was caught). **Live-tenant verification pending**: the query/field names are confirmed against the published schema but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a field mismatch, update the query strings here. Nullish hazards preserved: `agentSession.issue.id ?? thread.issue_id` and `endCursor ?? undefined` → `is not None` (NOT `or` — an empty issue id still short-circuits per `??`). Regression coverage: `tests/test_linear_agent_session_fetch.py`. | ### Platform-specific gaps | Area | Python | TS | Rationale | @@ -700,7 +701,7 @@ stay explicit instead of being rediscovered in code review. | Telegram `get_user().is_bot` | Always `False` (matches upstream — `getChat` does not expose `is_bot`) | Always `false` (same caveat documented in upstream code comment) | The Telegram Bot API's `getChat` endpoint does not surface the `is_bot` field that's available on the `User` object inside incoming `Message` updates. Callers needing bot detection must use `message.author.is_bot` from webhooks instead of `chat.get_user(...).is_bot`. | | WhatsApp `get_user` | Raises `ChatNotImplementedError` (`Chat.get_user` translates to "does not support get_user") | Not implemented upstream either (no `getUser` on the WhatsApp adapter) | WhatsApp Cloud API has no user lookup endpoint — phone numbers are the only stable identifier and there's no equivalent of `users.info` exposed to business apps. Documented explicitly so callers don't expect parity with Slack/Teams/Discord. | | Messenger `get_user` | Raising stub (`ChatNotImplementedError`); a Graph-API-backed impl is tracked as issue #132 | No `getUser` method on the Messenger adapter | **Parity — upstream Messenger has no user-lookup method**; the Python raising stub matches. (Meta's Graph API *could* back a real implementation, unlike WhatsApp — hence #132 stays open as an enhancement.) | -| Linear agent sessions | **In progress** (5-PR wave, **#151**). Landed on `main`: L1 agent-session types (`LinearAgentSessionThreadId`, `LinearAgentSessionCommentRawMessage`, `mode`/`kind`), L2 the `:s:{session}` thread-id encode/decode, L3 the webhook PARSE + routing (`_parse_message_from_agent_session_event`, `_handle_agent_session_event`). **L4 (this change)**: the agent-activity EMIT path — `post_message`/`start_typing`/`stream` session branches as raw GraphQL (see the "Linear agent-activity emit" divergence row above). Still pending: **L5** the agent-session FETCH path (`get_message` / history). | Full agent-sessions support (`adapter-linear` 4.27.0, `bc94f0a`): parses agent-session webhook events into messages, emits agent activity, and routes the agent-session thread id | Largest single gap from the 0.4.30 audit; pre-existing (present since 0.4.29). Being closed across the 4.31 wave — tracked in **#151**. | +| Linear agent sessions | **Complete** (5-PR wave, **#151** — Wave D done). All five landed on `main`: L1 agent-session types (`LinearAgentSessionThreadId`, `LinearAgentSessionCommentRawMessage`, `mode`/`kind`), L2 the `:s:{session}` thread-id encode/decode, L3 the webhook PARSE + routing (`_parse_message_from_agent_session_event`, `_handle_agent_session_event`), L4 the agent-activity EMIT path (`post_message`/`start_typing`/`stream` session branches as raw GraphQL — see the "Linear agent-activity emit" divergence row above), and **L5 (this change)**: the agent-session FETCH path (`fetch_messages` → `_fetch_agent_session_messages`, the `edit_message`/`delete_message` append-only guards, and `fetch_thread` `agentSessionId` metadata as raw GraphQL — see the "Linear agent-session fetch" divergence row above). | Full agent-sessions support (`adapter-linear` 4.27.0, `bc94f0a`): parses agent-session webhook events into messages, emits agent activity, fetches the session thread, and routes the agent-session thread id | Largest single gap from the 0.4.30 audit; pre-existing (present since 0.4.29). Closed across the 4.31 wave — tracked in **#151**. | | `adapter-web` (`@chat-adapter/web`) | Neither half ported. (a) The server-side `WebAdapter` is **deferred** — not yet ported; (b) the client subpaths are out of scope (see Rationale). | Two distinct things: (a) a server-side `WebAdapter` — an `Adapter` implementation serving a browser chat UI over the AI SDK UI stream protocol (`3490a8c`, vercel/chat#444); (b) React/Vue/Svelte client subpaths (`716e934`) | The `WebAdapter` (a) **is portable** to a Python server SDK (it's a standard `Adapter`) and is deferred, not excluded — a future wave can port it. The client subpaths (b) are genuinely browser-only (front-end framework bindings) and are out of scope for a Python server SDK. (Corrects the earlier over-broad "browser-only; no Python runtime" note in CHANGELOG.) | ### Serialization differences diff --git a/src/chat_sdk/adapters/linear/adapter.py b/src/chat_sdk/adapters/linear/adapter.py index c9a8978..e4645f2 100644 --- a/src/chat_sdk/adapters/linear/adapter.py +++ b/src/chat_sdk/adapters/linear/adapter.py @@ -189,6 +189,103 @@ } """ +# Agent-session FETCH query. The Python adapter has no ``@linear/sdk``; +# upstream's ``linear.agentSession(id)`` (which lazy-resolves ``issueId`` and the +# root ``comment`` relation off the SDK model) is ported as this raw GraphQL +# query. Schema-hardened against Linear's published GraphQL schema +# (``linear/packages/sdk/src/schema.graphql`` @ master): the root query is +# ``agentSession(id: String!): AgentSession!``; ``AgentSession`` exposes ``id: +# ID!`` and the nullable ``comment: Comment`` relation. +# +# CRITICAL — ``AgentSession`` has NO scalar ``issueId`` field in the published +# schema (it exposes only the ``issue: Issue`` relation). Upstream's +# ``agentSession.issueId`` works because the SDK model derives ``issueId`` from +# the serialized object; in raw GraphQL, requesting a non-existent ``issueId`` +# field server-rejects the WHOLE query (the L4 blocking-bug class). So we select +# the schema-valid ``issue { id }`` relation and derive the issue id from it — +# equivalent to upstream's ``agentSession.issueId``, and the same fallback shape +# upstream itself uses elsewhere (``agentSession.issueId ?? agentSession.issue?.id`` +# at index.ts:959). The ``comment { ... }`` selection requests exactly the +# sub-fields the author/metadata resolution (``_raw_message_from_source_comment``, +# a faithful ``parseMessageFromComment``) reads off the root comment. +_AGENT_SESSION_FETCH_QUERY = """ +query AgentSession($id: String!) { + agentSession(id: $id) { + id + issue { + id + } + comment { + id + body + parentId + createdAt + updatedAt + url + user { + id + displayName + name + email + avatarUrl + } + botActor { + id + name + userDisplayName + avatarUrl + } + } + } +} +""" + +# Agent-session children FETCH query. Ports upstream's +# ``linear.comments({ filter: { parent: { id: { eq: rootComment.id } } }, first|last })`` +# (index.ts:1793). Schema-hardened: the root ``comments(filter: CommentFilter, +# first: Int, last: Int, after: String): CommentConnection!`` query exists, and +# the ``CommentFilter.parent: NullableCommentFilter`` → ``.id: IDComparator`` → +# ``.eq: ID`` chain is all schema-valid. Pagination is direction-driven — +# ``forward`` → ``first``, otherwise ``last`` (upstream's ternary). The same +# ``Comment`` sub-fields as the root-comment selection plus ``pageInfo { +# hasNextPage endCursor }`` (both published ``PageInfo`` fields). +_AGENT_SESSION_CHILDREN_QUERY = """ +query AgentSessionComments( + $filter: CommentFilter + $first: Int + $last: Int + $after: String +) { + comments(filter: $filter, first: $first, last: $last, after: $after) { + nodes { + id + body + parentId + createdAt + updatedAt + url + user { + id + displayName + name + email + avatarUrl + } + botActor { + id + name + userDisplayName + avatarUrl + } + } + pageInfo { + hasNextPage + endCursor + } + } +} +""" + # Emoji mapping for Linear reactions (unicode) EMOJI_MAPPING: dict[str, str] = { "thumbs_up": "\U0001f44d", @@ -1387,10 +1484,21 @@ async def edit_message( message_id: str, message: AdapterPostableMessage, ) -> RawMessage: - """Edit an existing message (update a comment).""" + """Edit an existing message (update a comment). + + Agent-session activities are append-only: upstream guards the session + case first (index.ts:1408) and raises before any mutation. The comment + path below is byte-identical to before. + """ await self._ensure_valid_token() decoded = self.decode_thread_id(thread_id) + if decoded.agent_session_id: + raise AdapterError( + "Linear agent session activities are append-only and cannot be edited", + "linear", + ) + card = extract_card(message) body = card_to_linear_markdown(card) if card else self._format_converter.render_postable(message) @@ -1436,7 +1544,19 @@ async def edit_message( ) async def delete_message(self, thread_id: str, message_id: str) -> None: - """Delete a message (delete a comment).""" + """Delete a message (delete a comment). + + Agent-session activities are append-only: upstream decodes the thread and + guards the session case first (index.ts:1464), raising before any + network call. The comment path below is byte-identical to before. + """ + decoded = self.decode_thread_id(thread_id) + if decoded.agent_session_id: + raise AdapterError( + "Linear agent session activities are append-only and cannot be deleted", + "linear", + ) + await self._ensure_valid_token() await self._graphql_query( @@ -1521,11 +1641,120 @@ async def fetch_messages( limit = options.limit if options.limit is not None else 50 + if decoded.agent_session_id: + session = assert_agent_session_thread(decoded) + return await self._fetch_agent_session_messages(session, options) + if decoded.comment_id: return await self._fetch_comment_thread(thread_id, decoded.issue_id, decoded.comment_id, limit) return await self._fetch_issue_comments(thread_id, decoded.issue_id, limit) + async def _fetch_agent_session_messages( + self, + thread: LinearAgentSessionThreadId, + options: FetchOptions | None = None, + ) -> FetchResult: + """Fetch the visible comment thread associated with an agent session. + + Faithful port of upstream ``fetchAgentSessionMessages`` (index.ts:1771). + The Python adapter has no ``@linear/sdk``; upstream's + ``linear.agentSession(id)`` + ``linear.comments({filter})`` calls are + ported as the schema-hardened raw GraphQL queries + ``_AGENT_SESSION_FETCH_QUERY`` / ``_AGENT_SESSION_CHILDREN_QUERY``. + + - ``issue_id = agentSession.issue.id ?? thread.issue_id`` — nullish + (``is not None``). The published schema has no scalar ``issueId`` on + ``AgentSession`` (only the ``issue`` relation), so we read the issue id + off ``issue { id }`` — equivalent to upstream's ``agentSession.issueId``. + Raise ``AdapterError`` when neither yields an id. + - ``root_comment = agentSession.comment`` — raise ``AdapterError`` when + the session has no root comment. + - children pagination is direction-driven: ``forward`` → ``first``, + otherwise ``last`` (default limit 50), passing the + ``{parent: {id: {eq: root_comment.id}}}`` filter. + - each of ``[root_comment, *children.nodes]`` is parsed via the upstream + ``parseMessageFromComment(comment, issue_id, agent_session.id)`` + semantics — reusing L4's ``_raw_message_from_source_comment`` (author + user-vs-botActor resolution) + ``_parse_agent_session_message`` (the + ``parseMessage`` agent-session branch). Each resulting message's + ``thread_id`` therefore encodes the comment's OWN id plus the session + segment: ``linear:{issue_id}:c:{comment.id}:s:{agent_session_id}`` — + NOT a single fixed thread_id shared across messages — and is a mention. + - ``next_cursor = endCursor if hasNextPage else None`` (upstream's + ``hasNextPage ? (endCursor ?? undefined) : undefined``; ``is not None``). + """ + limit = options.limit if (options is not None and options.limit is not None) else 50 + + session_result = await self._graphql_query( + _AGENT_SESSION_FETCH_QUERY, + {"id": thread.agent_session_id}, + ) + agent_session = (session_result.get("data") or {}).get("agentSession") + if not agent_session: + raise AdapterError( + f"Linear agent session {thread.agent_session_id} is missing issueId", + "linear", + ) + + # ``agentSession.issueId ?? thread.issueId`` — but the published schema + # exposes the issue id only via the ``issue`` relation (no scalar + # ``issueId`` field), so read it off ``issue { id }``. Nullish, NOT + # truthiness: an empty-string issue id would still short-circuit, but the + # ``is not None`` guard matches upstream's ``??``. + session_issue = agent_session.get("issue") or {} + session_issue_id = session_issue.get("id") + issue_id = session_issue_id if session_issue_id is not None else thread.issue_id + if not issue_id: + raise AdapterError( + f"Linear agent session {thread.agent_session_id} is missing issueId", + "linear", + ) + + root_comment = agent_session.get("comment") + if not root_comment: + raise AdapterError( + f"Linear agent session {thread.agent_session_id} is missing a root comment", + "linear", + ) + + agent_session_id = cast("str", agent_session.get("id", "")) + + # ``options?.direction === "forward" ? { first } : { last }`` — forward + # paginates with ``first``, every other direction (incl. the default + # ``backward``/unset) with ``last``. Send the unused bound as ``None`` so + # GraphQL ignores it. + forward = options is not None and options.direction == "forward" + cursor = options.cursor if options is not None else None + children_result = await self._graphql_query( + _AGENT_SESSION_CHILDREN_QUERY, + { + "filter": {"parent": {"id": {"eq": root_comment.get("id")}}}, + "first": limit if forward else None, + "last": None if forward else limit, + "after": cursor, + }, + ) + + children = ((children_result.get("data") or {}).get("comments")) or {} + child_nodes = children.get("nodes") or [] + page_info = children.get("pageInfo") or {} + + # ``commentsToMessages([rootComment, ...children], issueId, agentSession.id)`` + # — each comment parsed via the ``parseMessageFromComment`` author logic + # (reused from L4) and the ``parseMessage`` agent-session branch, so each + # message encodes its OWN comment id in the thread id. + messages: list[Message] = [] + for node in [root_comment, *child_nodes]: + raw_message = self._raw_message_from_source_comment(node, issue_id, agent_session_id) + messages.append(self._parse_agent_session_message(cast("Any", raw_message.raw))) + + end_cursor = page_info.get("endCursor") + return FetchResult( + messages=messages, + next_cursor=end_cursor if page_info.get("hasNextPage") and end_cursor is not None else None, + ) + async def _fetch_issue_comments( self, thread_id: str, @@ -1711,6 +1940,10 @@ async def fetch_thread(self, thread_id: str) -> ThreadInfo: metadata={ "issueId": decoded.issue_id, "issue_id": decoded.issue_id, # snake_case alias for compatibility + # ``agentSessionId`` mirrors upstream's fetchThread metadata + # (index.ts:1928) — the decoded session id (``None`` for non- + # session threads), so session-aware callers can round-trip it. + "agentSessionId": decoded.agent_session_id, "identifier": issue.get("identifier"), "title": issue.get("title"), "url": issue.get("url"), diff --git a/tests/test_linear_agent_session_fetch.py b/tests/test_linear_agent_session_fetch.py new file mode 100644 index 0000000..2f70940 --- /dev/null +++ b/tests/test_linear_agent_session_fetch.py @@ -0,0 +1,573 @@ +"""Tests for the Linear agent-session FETCH / read path. + +Ported from packages/adapter-linear/src/index.test.ts (chat@4.31 / #151, the +L5 fetch surface). The Python adapter has no ``@linear/sdk`` — upstream's +``linear.agentSession(id)`` + ``linear.comments({filter})`` calls +(``fetchAgentSessionMessages``, index.ts:1771) are ported as raw GraphQL +queries against the published Linear schema: + +- ``agentSession(id: String!): AgentSession!`` — the ``AgentSession`` type has + NO scalar ``issueId`` field (only the ``issue`` relation), so the issue id is + read off ``issue { id }`` (equivalent to upstream's ``agentSession.issueId``). + The nullable ``comment: Comment`` relation is the root comment. +- ``comments(filter: CommentFilter, first/last/after): CommentConnection!`` with + the ``{parent: {id: {eq: root_comment.id}}}`` filter — ``forward`` paginates + with ``first``, every other direction with ``last``. + +Each test pins behaviour so a regression — a forward/backward (first↔last) swap, +a per-comment-id → fixed-thread-id collapse, a nullish (``??``) → ``or`` swap, a +missing append-only guard, or a ``hasNextPage`` cursor-logic flip — fails the +assertion. The append-only edit/delete guards (index.ts:1408 / 1464) are +covered here too. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from chat_sdk.adapters.linear.adapter import LinearAdapter +from chat_sdk.adapters.linear.types import LinearAdapterAPIKeyConfig, LinearAgentSessionThreadId +from chat_sdk.shared.errors import AdapterError +from chat_sdk.types import FetchOptions + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +WEBHOOK_SECRET = "test-webhook-secret" + +_SESSION_THREAD = "linear:issue-123:s:session-789" +_ISSUE_THREAD = "linear:issue-123" +_COMMENT_THREAD = "linear:issue-123:c:comment-root" + + +def _make_logger() -> MagicMock: + return MagicMock( + debug=MagicMock(), + info=MagicMock(), + warn=MagicMock(), + error=MagicMock(), + ) + + +def _make_adapter(logger: MagicMock | None = None) -> LinearAdapter: + """Agent-sessions-mode adapter with a known bot-user-id and default org.""" + if logger is None: + logger = _make_logger() + config = LinearAdapterAPIKeyConfig( + api_key="test-api-key", + webhook_secret=WEBHOOK_SECRET, + user_name="test-bot", + mode="agent-sessions", # type: ignore[arg-type] + logger=logger, + ) + adapter = LinearAdapter(config) + adapter._bot_user_id = "bot-user-id" + adapter._default_organization_id = "org-123" + # ``_ensure_valid_token`` runs a viewer query before fetch; stub it out so the + # only ``_graphql_query`` calls under test are the two fetch queries. + adapter._ensure_valid_token = AsyncMock(return_value=None) # type: ignore[method-assign] + return adapter + + +def _user_comment( + *, + comment_id: str, + body: str = "hello", + parent_id: str | None = None, +) -> dict[str, Any]: + """A comment authored by a human user (``user`` present, no ``botActor``).""" + comment: dict[str, Any] = { + "id": comment_id, + "body": body, + "parentId": parent_id, + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "url": f"https://linear.app/comment/{comment_id}", + "user": { + "id": "human-user-1", + "displayName": "ada", + "name": "Ada Lovelace", + "email": "ada@example.com", + "avatarUrl": "https://linear.app/avatar/ada.png", + }, + } + return comment + + +def _bot_comment( + *, + comment_id: str, + body: str = "agent reply", + parent_id: str | None = "comment-root", +) -> dict[str, Any]: + """A comment created by the app (no ``user``, a ``botActor`` fallback).""" + return { + "id": comment_id, + "body": body, + "parentId": parent_id, + "createdAt": "2025-06-01T12:00:05.000Z", + "updatedAt": "2025-06-01T12:00:09.000Z", + "url": f"https://linear.app/comment/{comment_id}", + "botActor": { + "id": "bot-user-id", + "name": "Test Bot", + "userDisplayName": "Test Bot", + }, + } + + +def _session_return( + *, + issue_id: str | None = "issue-123", + root_comment: Any = "default", + session_id: str = "session-789", +) -> dict[str, Any]: + """Wrap an ``agentSession`` node as a ``_graphql_query`` return value. + + The issue id is carried under the ``issue { id }`` RELATION — the real + server shape. ``AgentSession`` exposes NO scalar ``issueId`` field, so a + fixture emitting a flat ``issueId`` would fabricate a server-rejected field. + ``issue_id=None`` models a session whose issue relation is absent (so the + ``thread.issue_id`` fallback / missing-issueId raise is exercised). + """ + if root_comment == "default": + root_comment = _user_comment(comment_id="comment-root", body="root prompt") + agent_session: dict[str, Any] = { + "id": session_id, + "issue": {"id": issue_id} if issue_id is not None else None, + "comment": root_comment, + } + return {"data": {"agentSession": agent_session}} + + +def _children_return( + *, + nodes: list[dict[str, Any]] | None = None, + has_next_page: bool = False, + end_cursor: str | None = None, +) -> dict[str, Any]: + """Wrap a ``comments`` connection as a ``_graphql_query`` return value.""" + return { + "data": { + "comments": { + "nodes": nodes or [], + "pageInfo": {"hasNextPage": has_next_page, "endCursor": end_cursor}, + } + } + } + + +def _query_router(*returns: dict[str, Any]) -> AsyncMock: + """An ``_graphql_query`` AsyncMock returning ``returns`` in call order. + + The fetch path issues exactly two queries — the session query first, the + children query second — so a 2-tuple side-effect pins both. + """ + return AsyncMock(side_effect=list(returns)) + + +# =========================================================================== +# _fetch_agent_session_messages — happy path +# =========================================================================== + + +class TestFetchAgentSessionMessagesHappyPath: + @pytest.mark.asyncio + async def test_root_plus_children_become_messages(self) -> None: + adapter = _make_adapter() + child_a = _bot_comment(comment_id="comment-a", body="first reply") + child_b = _bot_comment(comment_id="comment-b", body="second reply") + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(nodes=[child_a, child_b]), + ) + + result = await adapter.fetch_messages(_SESSION_THREAD) + + # Root comment is the first message, then each child in order. + assert [m.id for m in result.messages] == ["comment-root", "comment-a", "comment-b"] + assert [m.text for m in result.messages] == ["root prompt", "first reply", "second reply"] + + # PER-COMMENT thread_id proof: each message encodes its OWN comment id + # plus the session segment — NOT a single fixed thread_id shared by all. + # A regression that passed one fixed thread_id (e.g. the root's) to every + # message would collapse these to identical strings. + assert [m.thread_id for m in result.messages] == [ + "linear:issue-123:c:comment-root:s:session-789", + "linear:issue-123:c:comment-a:s:session-789", + "linear:issue-123:c:comment-b:s:session-789", + ] + assert len({m.thread_id for m in result.messages}) == 3 + + # Agent-session comments directly target the bot → every message is a + # mention (upstream ``parseMessage`` sets ``isMention`` for the + # ``agent_session_comment`` kind). + assert all(m.is_mention for m in result.messages) + + @pytest.mark.asyncio + async def test_author_resolution_user_vs_bot(self) -> None: + adapter = _make_adapter() + # Root authored by a human user; child created by the app (botActor). + root = _user_comment(comment_id="comment-root", body="human prompt") + child = _bot_comment(comment_id="comment-a", body="bot reply") + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(root_comment=root), + _children_return(nodes=[child]), + ) + + result = await adapter.fetch_messages(_SESSION_THREAD) + + root_msg, child_msg = result.messages + # User author: not a bot, not me, display name from the comment's user. + assert root_msg.author.is_bot is False + assert root_msg.author.user_id == "human-user-1" + assert root_msg.author.user_name == "ada" + assert root_msg.author.is_me is False + # Bot author: botActor fallback, bot-user-id matches → is_me true. + assert child_msg.author.is_bot is True + assert child_msg.author.user_id == "bot-user-id" + assert child_msg.author.is_me is True + + @pytest.mark.asyncio + async def test_dispatch_calls_session_then_children_queries(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(), + ) + + await adapter.fetch_messages(_SESSION_THREAD) + + # First query resolves the agent session by id and selects ``issue { id }`` + # (NOT a scalar ``issueId`` field, which would server-reject the query). + first_query, first_vars = adapter._graphql_query.call_args_list[0][0] + assert "agentSession(id: $id)" in first_query + assert "issue {" in first_query + # The scalar ``issueId`` must NOT be selected on AgentSession. + assert "issueId" not in first_query + assert first_vars == {"id": "session-789"} + + # Second query filters children by parent id and selects pageInfo. + second_query, second_vars = adapter._graphql_query.call_args_list[1][0] + assert "comments(" in second_query + assert "hasNextPage" in second_query + assert second_vars["filter"] == {"parent": {"id": {"eq": "comment-root"}}} + + +# =========================================================================== +# _fetch_agent_session_messages — issueId fallback + raises +# =========================================================================== + + +class TestFetchAgentSessionMessagesIssueId: + @pytest.mark.asyncio + async def test_falls_back_to_thread_issue_id_when_session_issue_absent(self) -> None: + adapter = _make_adapter() + # Session has no ``issue`` relation; the thread's own issue id is used. + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(issue_id=None), + _children_return(), + ) + + result = await adapter.fetch_messages(_SESSION_THREAD) + + # The root message's thread id is built from the THREAD's issue id + # (issue-123) — proving the ``agentSession.issue.id ?? thread.issue_id`` + # fallback fired. + assert result.messages[0].thread_id == "linear:issue-123:c:comment-root:s:session-789" + + @pytest.mark.asyncio + async def test_raises_when_issue_id_missing_everywhere(self) -> None: + adapter = _make_adapter() + # Thread carries no issue id AND the session has no issue relation, so + # the ``agentSession.issue.id ?? thread.issue_id`` fallback yields + # nothing. (Called directly: a thread id can't encode an empty issue id, + # so this guard is reached via a degenerate decoded thread.) + adapter._graphql_query = AsyncMock(return_value=_session_return(issue_id=None)) # type: ignore[method-assign] + thread = LinearAgentSessionThreadId(issue_id="", agent_session_id="session-789") + + with pytest.raises(AdapterError) as exc_info: + await adapter._fetch_agent_session_messages(thread) + assert "missing issueId" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_raises_when_root_comment_missing(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = AsyncMock(return_value=_session_return(root_comment=None)) + + with pytest.raises(AdapterError) as exc_info: + await adapter.fetch_messages(_SESSION_THREAD) + assert "missing a root comment" in str(exc_info.value) + + +# =========================================================================== +# _fetch_agent_session_messages — pagination (forward → first, backward → last) +# =========================================================================== + + +class TestFetchAgentSessionMessagesPagination: + @pytest.mark.asyncio + async def test_forward_uses_first(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(), + ) + + await adapter.fetch_messages(_SESSION_THREAD, FetchOptions(direction="forward", limit=10)) + + _, children_vars = adapter._graphql_query.call_args_list[1][0] + # forward → ``first`` carries the limit, ``last`` is None. + assert children_vars["first"] == 10 + assert children_vars["last"] is None + + @pytest.mark.asyncio + async def test_backward_uses_last(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(), + ) + + await adapter.fetch_messages(_SESSION_THREAD, FetchOptions(direction="backward", limit=10)) + + _, children_vars = adapter._graphql_query.call_args_list[1][0] + # backward → ``last`` carries the limit, ``first`` is None. A forward/ + # backward swap would flip these. + assert children_vars["last"] == 10 + assert children_vars["first"] is None + + @pytest.mark.asyncio + async def test_default_direction_uses_last(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(), + ) + + # No options → not forward → ``last``, default limit 50. + await adapter.fetch_messages(_SESSION_THREAD) + + _, children_vars = adapter._graphql_query.call_args_list[1][0] + assert children_vars["last"] == 50 + assert children_vars["first"] is None + + @pytest.mark.asyncio + async def test_cursor_passed_as_after(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(), + ) + + await adapter.fetch_messages(_SESSION_THREAD, FetchOptions(cursor="cursor-xyz")) + + _, children_vars = adapter._graphql_query.call_args_list[1][0] + assert children_vars["after"] == "cursor-xyz" + + +# =========================================================================== +# _fetch_agent_session_messages — next_cursor by hasNextPage +# =========================================================================== + + +class TestFetchAgentSessionMessagesNextCursor: + @pytest.mark.asyncio + async def test_next_cursor_present_when_has_next_page(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(has_next_page=True, end_cursor="cursor-next"), + ) + + result = await adapter.fetch_messages(_SESSION_THREAD) + + assert result.next_cursor == "cursor-next" + + @pytest.mark.asyncio + async def test_next_cursor_absent_when_no_next_page(self) -> None: + adapter = _make_adapter() + # endCursor is present, but hasNextPage is False → next_cursor is None. + # A regression that returned endCursor regardless of hasNextPage would + # leak ``cursor-stale`` here. + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(), + _children_return(has_next_page=False, end_cursor="cursor-stale"), + ) + + result = await adapter.fetch_messages(_SESSION_THREAD) + + assert result.next_cursor is None + + +# =========================================================================== +# Append-only guards — edit / delete +# =========================================================================== + + +class TestAppendOnlyGuards: + @pytest.mark.asyncio + async def test_edit_message_raises_for_agent_session(self) -> None: + adapter = _make_adapter() + # The guard must fire before any GraphQL mutation. A failing AsyncMock + # proves no mutation was attempted (the guard short-circuits first). + adapter._graphql_query = AsyncMock(side_effect=AssertionError("must not run a mutation")) # type: ignore[method-assign] + + with pytest.raises(AdapterError) as exc_info: + await adapter.edit_message(_SESSION_THREAD, "comment-a", "new body") + assert str(exc_info.value) == "Linear agent session activities are append-only and cannot be edited" + + @pytest.mark.asyncio + async def test_delete_message_raises_for_agent_session(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = AsyncMock(side_effect=AssertionError("must not run a mutation")) # type: ignore[method-assign] + + with pytest.raises(AdapterError) as exc_info: + await adapter.delete_message(_SESSION_THREAD, "comment-a") + assert str(exc_info.value) == "Linear agent session activities are append-only and cannot be deleted" + + @pytest.mark.asyncio + async def test_edit_message_still_works_for_comment_thread(self) -> None: + """Regression: the comment path edit must be UNCHANGED by the new guard.""" + adapter = _make_adapter() + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + return_value={ + "data": { + "commentUpdate": { + "success": True, + "comment": { + "id": "comment-root", + "body": "edited", + "url": "https://linear.app/comment/comment-root", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:05:00.000Z", + }, + } + } + } + ) + + result = await adapter.edit_message(_COMMENT_THREAD, "comment-root", "edited") + + assert result.id == "comment-root" + assert adapter._graphql_query.await_count == 1 + + @pytest.mark.asyncio + async def test_delete_message_still_works_for_comment_thread(self) -> None: + """Regression: the comment path delete must be UNCHANGED by the new guard.""" + adapter = _make_adapter() + adapter._graphql_query = AsyncMock(return_value={"data": {"commentDelete": {"success": True}}}) # type: ignore[method-assign] + + await adapter.delete_message(_COMMENT_THREAD, "comment-root") + + assert adapter._graphql_query.await_count == 1 + + +# =========================================================================== +# Comment-path fetch — UNCHANGED regression +# =========================================================================== + + +class TestCommentPathFetchUnchanged: + @pytest.mark.asyncio + async def test_issue_thread_fetch_uses_issue_comments_query(self) -> None: + """A non-session, non-comment thread still routes to issue-comments.""" + adapter = _make_adapter() + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + return_value={ + "data": { + "issue": { + "comments": { + "nodes": [ + { + "id": "comment-1", + "body": "top-level", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "url": "https://linear.app/comment/comment-1", + "user": {"id": "u1", "displayName": "u", "name": "User"}, + } + ], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + } + } + } + } + ) + + result = await adapter.fetch_messages(_ISSUE_THREAD) + + query = adapter._graphql_query.call_args[0][0] + # Issue-comments query, NOT the agent-session query. + assert "issue(id: $issueId)" in query + assert "agentSession" not in query + assert [m.id for m in result.messages] == ["comment-1"] + # The comment path keeps the FIXED thread_id (the passed thread id) — it + # is NOT re-encoded per comment like the session path. + assert result.messages[0].thread_id == _ISSUE_THREAD + + @pytest.mark.asyncio + async def test_comment_thread_fetch_uses_comment_query(self) -> None: + """A ``:c:`` thread still routes to the comment-thread fetch unchanged.""" + adapter = _make_adapter() + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + return_value={ + "data": { + "comment": { + "id": "comment-root", + "body": "root", + "createdAt": "2025-06-01T12:00:00.000Z", + "updatedAt": "2025-06-01T12:00:00.000Z", + "url": "https://linear.app/comment/comment-root", + "user": {"id": "u1", "displayName": "u", "name": "User"}, + "children": { + "nodes": [], + "pageInfo": {"hasNextPage": False, "endCursor": None}, + }, + } + } + } + ) + + result = await adapter.fetch_messages(_COMMENT_THREAD) + + query = adapter._graphql_query.call_args[0][0] + assert "comment(id: $commentId)" in query + assert "agentSession" not in query + assert [m.id for m in result.messages] == ["comment-root"] + # Comment path keeps the fixed thread_id. + assert result.messages[0].thread_id == _COMMENT_THREAD + + +# =========================================================================== +# fetch_thread — agentSessionId metadata +# =========================================================================== + + +class TestFetchThreadAgentSessionId: + @pytest.mark.asyncio + async def test_metadata_includes_agent_session_id(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + return_value={"data": {"issue": {"identifier": "ENG-1", "title": "Title", "url": "https://x"}}} + ) + + info = await adapter.fetch_thread(_SESSION_THREAD) + + assert info.metadata["agentSessionId"] == "session-789" + assert info.metadata["issueId"] == "issue-123" + + @pytest.mark.asyncio + async def test_metadata_agent_session_id_none_for_non_session(self) -> None: + adapter = _make_adapter() + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + return_value={"data": {"issue": {"identifier": "ENG-1", "title": "Title", "url": "https://x"}}} + ) + + info = await adapter.fetch_thread(_ISSUE_THREAD) + + assert info.metadata["agentSessionId"] is None From 754a58b7f3ec1b059d171653f6ae1ce76ae7805c Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Sat, 20 Jun 2026 01:09:41 -0700 Subject: [PATCH 2/2] fix(linear): drop unused cursor forwarding + faithfulness/coverage polish on agent-session fetch (L5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the inbound `after: options.cursor` forwarding from `_fetch_agent_session_messages` and the `$after: String` param from `_AGENT_SESSION_CHILDREN_QUERY`. Upstream `fetchAgentSessionMessages` (index.ts:1793-1804) passes ONLY `first`/`last` and never reads `options.cursor`; the sibling `_fetch_issue_comments`/`_fetch_comment_thread` paths also forward no cursor. `next_cursor` is still returned off `pageInfo.endCursor`. Removes the undocumented divergence; updates the L5 UPSTREAM_SYNC.md row accordingly. - Fix the misleading null-session guard message: when the raw-GraphQL `agentSession(id)` resolves to null (port-only branch — the SDK throws its own not-found upstream), raise "... not found" instead of "... is missing issueId". The separate downstream missing-issueId raise is unchanged. - Add tests pinning: comment-session thread id dispatches to the agent-session fetch (branch-swap mutation); empty-string session issue.id is kept per nullish (`??`/`is not None`) not truthiness (`or`); null-session not-found message; `endCursor` only returned when both hasNextPage and endCursor present. Each new test fails on the corresponding mutation (verified by injection). --- docs/UPSTREAM_SYNC.md | 2 +- src/chat_sdk/adapters/linear/adapter.py | 23 +-- tests/test_linear_agent_session_fetch.py | 179 +++++++++++++++++++++-- 3 files changed, 184 insertions(+), 20 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 45473fe..269f754 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -686,7 +686,7 @@ stay explicit instead of being rediscovered in code review. | Teams `graph` defensive shape coercion (vercel/chat#8c71411, chat@4.31) | `to_graph_message` / channel + message readers coerce unexpected Graph payload shapes with `isinstance` guards (`x if isinstance(x, Mapping) else {}`, `value if isinstance(value, list) else []`) before reading fields | Upstream `graph/messages.ts` reads `message.from?.user` / `message.body?.content ?? ""` etc. with optional chaining — a non-object where an object is expected throws at the property access | Benign defensive divergence. Upstream's optional chaining tolerates `null`/`undefined` but throws on a wrong-typed non-null (e.g. a string where an object is expected); our `isinstance` coercion fails closed to an empty mapping/list instead of raising. For well-formed Graph responses the behavior is identical; the divergence only manifests on malformed payloads, where returning an empty-shape result is more resilient than throwing. Mirrors the repo's general "more resilient than throw" stance (cf. the `renderPostable on unknown input` row). | | `ThinkingChunk` opt-in stream-input type (Python-only, default-off; supersedes PR #39) | A **separate, opt-in** dataclass — `ThinkingChunk(type="thinking", content=str)` — surfaces AI-SDK `reasoning`/`reasoning-delta` (and pydantic-ai `part_kind == "thinking"`) parts. **`StreamChunk` is NOT widened**: the canonical union stays `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — byte-identical to upstream's three variants, so a consumer doing an exhaustive `match` over `StreamChunk` sees zero change on upgrade. `ThinkingChunk` is accepted only at the **stream-input/output boundaries** via the public alias `StreamInput = str \| StreamChunk \| ThinkingChunk` (the `Adapter.stream()` protocol signature, `from_full_stream`/`_from_full_stream` returns, `Thread._wrapped_stream`, and each receiving adapter's `stream()` signature). A producer can yield `ThinkingChunk` (opt-in) and the adapters that receive the stream type-check; code that only references `StreamChunk` never touches it. **OPT-IN, default-off**: emitted only when a caller passes `from_full_stream(stream, emit_thinking=True)` or sets the thread-level `emit_thinking=True` config; the internal `_from_full_stream` threads the same flag. With the default (`emit_thinking=False`) the normalized stream is **byte-for-byte identical** to upstream — reasoning parts are dropped and **no** `ThinkingChunk` is produced. Consumption is graceful: `Thread._handle_stream` never accumulates a `ThinkingChunk` into the posted-message text, and every adapter's stream handler skips it (Slack/Teams expose an optional `render_thinking` hook via `chat_sdk.shared.adapter_utils.maybe_render_thinking`; the text-accumulate adapters ignore it structurally). **Streaming-only — never persisted**: `Message` has no `thinking` field, `to_json()` is unchanged, and a round-tripped `Message` is byte-identical, so cross-SDK state (Redis/Postgres shared with the TS SDK) stays compatible. | Upstream `from-full-stream.ts` forwards only `text-delta` + `finish-step`; AI-SDK `reasoning`/`reasoning-delta` parts fall through and are discarded. `StreamChunk = MarkdownTextChunk \| TaskUpdateChunk \| PlanUpdateChunk` — no reasoning variant and no stream-input alias. Upstream leaves reasoning display to the AI-SDK web UI. | chinchill actively streams agent thinking to Slack/Teams but has to intercept the model stream out-of-band today because the chat-platform SDK has no path for it. This gives the SDK a first-class, opt-in one without changing any default behavior — and crucially **without widening the public `StreamChunk` union**, so consumers referencing it are unaffected. The whole design constraint is that default-off == upstream and `StreamChunk` == upstream: separate opt-in input type, opt-in emit, graceful/skip consume, zero state pollution. Regression coverage: `tests/test_thinking_chunk.py`, `tests/test_from_full_stream.py::TestThinkingOptIn`, `tests/test_types.py::TestThinkingChunk`, plus per-adapter no-crash tests in `tests/test_slack_api.py`, `tests/test_teams_native_streaming.py`, `tests/test_twilio_adapter.py`, `tests/test_messenger_api.py`. | | Linear agent-activity emit: raw GraphQL (chat@4.31 / #151 — L4) | The agent-session EMIT path (`post_message` session branch, `start_typing` session branch, `stream` → `_stream_in_agent_session` with its flush/`task_update`/`plan_update` logic, and `_parse_message_from_agent_activity`) is ported as **raw GraphQL mutations** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema**. Mutation names: `agentActivityCreate(input: AgentActivityCreateInput!)` and `agentSessionUpdate(id: String!, input: AgentSessionUpdateInput!)`. `content` is sent inside the `AgentActivityCreateInput.content` **`JSONObject!`** scalar — so `type`/`body`/`action`/`parameter`/`result` are inline JSON fields, with the **lowercase** `AgentActivityType` enum values `"response"` / `"thought"` / `"error"` / `"action"` (confirmed lowercase, NOT PascalCase). `ephemeral: Boolean` is a sibling of `content` and is **included only when set** (absent — not `false` — on response/thought/error). The `agentActivityCreate` return selection requests only schema-valid fields (`success`, `agentActivity { id agentSession { id } sourceComment { id body parentId createdAt updatedAt url user{…} botActor{…} } }`) — `agentSessionId` is **not** a scalar field on Linear's `AgentActivity` type (the schema exposes only the relation `agentSession: AgentSession!`), so the session id is read off the nested `agentSession { id }` relation; requesting the non-existent scalar would server-reject the whole mutation under GraphQL strict selection validation. `plan` items are `{content, status:"completed"}`. `initialize` additionally captures the viewer's `organization.id` into `_default_organization_id` (mirroring upstream's `defaultOrganizationId`) for the emitted raw message's `organizationId`; on the emit path `organizationId` falls back to `""` when `_default_organization_id` is unset (no per-request installation context is plumbed — a pre-existing adapter-wide divergence from upstream, which throws `AuthenticationError` when no organization is resolvable). | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `createAgentActivity({agentSessionId, content, ephemeral?})` / `updateAgentSession(id, {plan})`; the SDK owns the GraphQL document, the `AgentActivityType` enum, and the `AgentActivityPayload`/`Comment`/`sourceComment` resolution | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Mutation names, the `content: JSONObject!` shape, the lowercase enum casing, and the `plan` item shape are **schema-hardened against the published schema** (`https://linear.app/developers/agent-interaction`, `https://linear.app/developers/graphql`, and the SDK's generated GraphQL documents). **Live-tenant verification pending**: the exact mutation/field names and enum casing are confirmed against the published schema/docs but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a casing/field mismatch (e.g. an enum the schema renders differently at runtime), update the mutation strings here. Faithful-port hazards preserved: `status ?? "Thinking..."` → `is not None` (an empty status stays `""`); `[title, output].filter(Boolean).join("\n")` → `"\n".join(x for x in [title, output] if x)` (drops `None` and `""`); `markdown.slice(...).trim()` uses the JS-`.trim()` whitespace set (`_JS_WHITESPACE`, mirroring `adapters/telegram/rich.py`), not Python's broader `str.strip()`; `if delta or force`; `ephemeral: status != "complete"`; the missing-final-flush bare `throw new Error(...)` → `RuntimeError`. Regression coverage: `tests/test_linear_agent_session_emit.py`. | -| Linear agent-session fetch: raw GraphQL (chat@4.31 / #151 — L5) | The agent-session FETCH/read path (`fetch_messages` session dispatch → `_fetch_agent_session_messages`, plus the append-only guards on `edit_message`/`delete_message` and the `agentSessionId` key in `fetch_thread` metadata) is ported as **raw GraphQL queries** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema** (`linear/packages/sdk/src/schema.graphql` @ master). Two queries: (1) `agentSession(id: String!): AgentSession!` selecting `id`, `issue { id }`, and the nullable `comment { id body parentId createdAt updatedAt url user{…} botActor{…} }` root relation; (2) `comments(filter: CommentFilter, first: Int, last: Int, after: String): CommentConnection!` filtered by `{parent: {id: {eq: rootComment.id}}}` for the children, selecting the same `Comment` sub-fields + `pageInfo { hasNextPage endCursor }`. **CRITICAL schema-hardening: `AgentSession` has NO scalar `issueId` field in the published schema** (it exposes only the `issue: Issue` relation alongside `comment`/`sourceComment`/`id`); upstream's `agentSession.issueId` works because `@linear/sdk`'s model derives it from the serialized object, but in raw GraphQL requesting a non-existent `issueId` field would server-reject the whole query (the L4 blocking-bug class). So the issue id is read off the `issue { id }` relation — equivalent to upstream's `agentSession.issueId ?? thread.issueId`, and the same `issueId ?? issue?.id` fallback upstream itself uses at `index.ts:959`. Pagination is direction-driven (`forward` → `first`, otherwise `last`, default limit 50); `next_cursor = endCursor if hasNextPage else None`. Each of `[rootComment, *children.nodes]` is parsed via the upstream `parseMessageFromComment(comment, issueId, agentSession.id)` semantics — reusing L4's `_raw_message_from_source_comment` (user-vs-`botActor` author resolution) + `_parse_agent_session_message` (the `parseMessage` agent-session branch), so **each message's `thread_id` encodes the comment's OWN id** (`linear:{issueId}:c:{comment.id}:s:{agentSessionId}`, NOT a single fixed thread id) and `is_mention=True`. `edit_message`/`delete_message` raise `AdapterError` with the exact upstream strings ("…append-only and cannot be edited" / "…cannot be deleted") for session threads, before any network call. | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `linear.agentSession(id)` (lazy-resolving `issueId` + the `comment` relation off the SDK model) and `linear.comments({filter, first/last})`; the SDK owns the GraphQL documents and the `Comment`/author resolution. | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Query names, the `agentSession(id)` shape, the root `comments(filter: CommentFilter, first/last/after)` connection, the `CommentFilter.parent → NullableCommentFilter.id → IDComparator.eq` chain, and every selected `AgentSession`/`Comment`/`User`/`ActorBot`/`PageInfo` field were each **verified field-by-field against the published `schema.graphql`** (this is how the absence of a scalar `AgentSession.issueId` was caught). **Live-tenant verification pending**: the query/field names are confirmed against the published schema but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a field mismatch, update the query strings here. Nullish hazards preserved: `agentSession.issue.id ?? thread.issue_id` and `endCursor ?? undefined` → `is not None` (NOT `or` — an empty issue id still short-circuits per `??`). Regression coverage: `tests/test_linear_agent_session_fetch.py`. | +| Linear agent-session fetch: raw GraphQL (chat@4.31 / #151 — L5) | The agent-session FETCH/read path (`fetch_messages` session dispatch → `_fetch_agent_session_messages`, plus the append-only guards on `edit_message`/`delete_message` and the `agentSessionId` key in `fetch_thread` metadata) is ported as **raw GraphQL queries** over the existing `_graphql_query` helper, **schema-hardened against Linear's published GraphQL schema** (`linear/packages/sdk/src/schema.graphql` @ master). Two queries: (1) `agentSession(id: String!): AgentSession!` selecting `id`, `issue { id }`, and the nullable `comment { id body parentId createdAt updatedAt url user{…} botActor{…} }` root relation; (2) `comments(filter: CommentFilter, first: Int, last: Int): CommentConnection!` filtered by `{parent: {id: {eq: rootComment.id}}}` for the children, selecting the same `Comment` sub-fields + `pageInfo { hasNextPage endCursor }`. Upstream passes ONLY `first`/`last` here — it never reads `options.cursor` — and the sibling `_fetch_issue_comments`/`_fetch_comment_thread` paths forward no cursor either, so no inbound `after` is plumbed (only `next_cursor` is RETURNED, off `pageInfo.endCursor`). **CRITICAL schema-hardening: `AgentSession` has NO scalar `issueId` field in the published schema** (it exposes only the `issue: Issue` relation alongside `comment`/`sourceComment`/`id`); upstream's `agentSession.issueId` works because `@linear/sdk`'s model derives it from the serialized object, but in raw GraphQL requesting a non-existent `issueId` field would server-reject the whole query (the L4 blocking-bug class). So the issue id is read off the `issue { id }` relation — equivalent to upstream's `agentSession.issueId ?? thread.issueId`, and the same `issueId ?? issue?.id` fallback upstream itself uses at `index.ts:959`. Pagination is direction-driven (`forward` → `first`, otherwise `last`, default limit 50); `next_cursor = endCursor if hasNextPage else None`. Each of `[rootComment, *children.nodes]` is parsed via the upstream `parseMessageFromComment(comment, issueId, agentSession.id)` semantics — reusing L4's `_raw_message_from_source_comment` (user-vs-`botActor` author resolution) + `_parse_agent_session_message` (the `parseMessage` agent-session branch), so **each message's `thread_id` encodes the comment's OWN id** (`linear:{issueId}:c:{comment.id}:s:{agentSessionId}`, NOT a single fixed thread id) and `is_mention=True`. `edit_message`/`delete_message` raise `AdapterError` with the exact upstream strings ("…append-only and cannot be edited" / "…cannot be deleted") for session threads, before any network call. | Upstream `adapter-linear/src/index.ts` calls `@linear/sdk`'s `linear.agentSession(id)` (lazy-resolving `issueId` + the `comment` relation off the SDK model) and `linear.comments({filter, first/last})`; the SDK owns the GraphQL documents and the `Comment`/author resolution. | `@linear/sdk` is TypeScript-only (no official Linear Python SDK — cf. the `linear_client` getter row), so the SDK calls are reproduced as raw GraphQL. Query names, the `agentSession(id)` shape, the root `comments(filter: CommentFilter, first/last)` connection, the `CommentFilter.parent → NullableCommentFilter.id → IDComparator.eq` chain, and every selected `AgentSession`/`Comment`/`User`/`ActorBot`/`PageInfo` field were each **verified field-by-field against the published `schema.graphql`** (this is how the absence of a scalar `AgentSession.issueId` was caught). **Live-tenant verification pending**: the query/field names are confirmed against the published schema but have **not** been exercised against a live Linear agent-session tenant; if a future live run surfaces a field mismatch, update the query strings here. Nullish hazards preserved: `agentSession.issue.id ?? thread.issue_id` and `endCursor ?? undefined` → `is not None` (NOT `or` — an empty issue id still short-circuits per `??`). Regression coverage: `tests/test_linear_agent_session_fetch.py`. | ### Platform-specific gaps | Area | Python | TS | Rationale | diff --git a/src/chat_sdk/adapters/linear/adapter.py b/src/chat_sdk/adapters/linear/adapter.py index e4645f2..c077b68 100644 --- a/src/chat_sdk/adapters/linear/adapter.py +++ b/src/chat_sdk/adapters/linear/adapter.py @@ -243,20 +243,22 @@ # Agent-session children FETCH query. Ports upstream's # ``linear.comments({ filter: { parent: { id: { eq: rootComment.id } } }, first|last })`` # (index.ts:1793). Schema-hardened: the root ``comments(filter: CommentFilter, -# first: Int, last: Int, after: String): CommentConnection!`` query exists, and +# first: Int, last: Int): CommentConnection!`` query exists, and # the ``CommentFilter.parent: NullableCommentFilter`` → ``.id: IDComparator`` → # ``.eq: ID`` chain is all schema-valid. Pagination is direction-driven — -# ``forward`` → ``first``, otherwise ``last`` (upstream's ternary). The same -# ``Comment`` sub-fields as the root-comment selection plus ``pageInfo { -# hasNextPage endCursor }`` (both published ``PageInfo`` fields). +# ``forward`` → ``first``, otherwise ``last`` (upstream's ternary). Upstream +# passes ONLY ``first``/``last`` here — it never reads ``options.cursor`` — and +# the sibling ``_fetch_issue_comments``/``_fetch_comment_thread`` paths likewise +# forward no cursor, so no ``after`` is sent. The same ``Comment`` sub-fields as +# the root-comment selection plus ``pageInfo { hasNextPage endCursor }`` (both +# published ``PageInfo`` fields). _AGENT_SESSION_CHILDREN_QUERY = """ query AgentSessionComments( $filter: CommentFilter $first: Int $last: Int - $after: String ) { - comments(filter: $filter, first: $first, last: $last, after: $after) { + comments(filter: $filter, first: $first, last: $last) { nodes { id body @@ -1692,8 +1694,13 @@ async def _fetch_agent_session_messages( ) agent_session = (session_result.get("data") or {}).get("agentSession") if not agent_session: + # Port-only guard: upstream has no null-session branch — the + # ``@linear/sdk`` ``linear.agentSession(id)`` throws its own + # not-found. The raw-GraphQL port returns ``null`` instead, so this + # guard describes the REAL failure (session not found), distinct from + # the downstream missing-``issueId`` raise after the session resolves. raise AdapterError( - f"Linear agent session {thread.agent_session_id} is missing issueId", + f"Linear agent session {thread.agent_session_id} not found", "linear", ) @@ -1725,14 +1732,12 @@ async def _fetch_agent_session_messages( # ``backward``/unset) with ``last``. Send the unused bound as ``None`` so # GraphQL ignores it. forward = options is not None and options.direction == "forward" - cursor = options.cursor if options is not None else None children_result = await self._graphql_query( _AGENT_SESSION_CHILDREN_QUERY, { "filter": {"parent": {"id": {"eq": root_comment.get("id")}}}, "first": limit if forward else None, "last": None if forward else limit, - "after": cursor, }, ) diff --git a/tests/test_linear_agent_session_fetch.py b/tests/test_linear_agent_session_fetch.py index 2f70940..2e3c210 100644 --- a/tests/test_linear_agent_session_fetch.py +++ b/tests/test_linear_agent_session_fetch.py @@ -10,9 +10,11 @@ NO scalar ``issueId`` field (only the ``issue`` relation), so the issue id is read off ``issue { id }`` (equivalent to upstream's ``agentSession.issueId``). The nullable ``comment: Comment`` relation is the root comment. -- ``comments(filter: CommentFilter, first/last/after): CommentConnection!`` with +- ``comments(filter: CommentFilter, first/last): CommentConnection!`` with the ``{parent: {id: {eq: root_comment.id}}}`` filter — ``forward`` paginates - with ``first``, every other direction with ``last``. + with ``first``, every other direction with ``last``. Upstream passes ONLY + ``first``/``last`` (it never reads ``options.cursor``), so no ``after`` is + forwarded — matching the sibling issue/comment fetch paths. Each test pins behaviour so a regression — a forward/backward (first↔last) swap, a per-comment-id → fixed-thread-id collapse, a nullish (``??``) → ``or`` swap, a @@ -357,7 +359,15 @@ async def test_default_direction_uses_last(self) -> None: assert children_vars["first"] is None @pytest.mark.asyncio - async def test_cursor_passed_as_after(self) -> None: + async def test_inbound_cursor_is_not_forwarded_as_after(self) -> None: + """Faithfulness: upstream ``fetchAgentSessionMessages`` passes ONLY + ``first``/``last`` — it never reads ``options.cursor`` — and the sibling + ``_fetch_issue_comments``/``_fetch_comment_thread`` paths forward no + cursor either. So an inbound ``cursor`` must NOT be plumbed to the + children query as ``after``. A regression that re-introduced + ``"after": options.cursor`` (or restored ``$after`` to the query) would + surface here. + """ adapter = _make_adapter() adapter._graphql_query = _query_router( # type: ignore[method-assign] _session_return(), @@ -366,8 +376,13 @@ async def test_cursor_passed_as_after(self) -> None: await adapter.fetch_messages(_SESSION_THREAD, FetchOptions(cursor="cursor-xyz")) - _, children_vars = adapter._graphql_query.call_args_list[1][0] - assert children_vars["after"] == "cursor-xyz" + children_query, children_vars = adapter._graphql_query.call_args_list[1][0] + # No ``after`` variable is sent, and the query declares no ``$after`` param. + assert "after" not in children_vars + assert "$after" not in children_query + assert "after:" not in children_query + # Only the pagination bounds and filter are forwarded. + assert set(children_vars) == {"filter", "first", "last"} # =========================================================================== @@ -389,21 +404,165 @@ async def test_next_cursor_present_when_has_next_page(self) -> None: assert result.next_cursor == "cursor-next" @pytest.mark.asyncio - async def test_next_cursor_absent_when_no_next_page(self) -> None: + @pytest.mark.parametrize( + ("has_next_page", "end_cursor"), + [ + # endCursor present but hasNextPage False → suppressed by the + # ``hasNextPage`` term. A mutation returning endCursor unconditionally + # (``next_cursor=end_cursor``, or dropping the ``hasNextPage`` guard) + # would leak ``cursor-stale`` here. + (False, "cursor-stale"), + # hasNextPage True but endCursor absent → ``next_cursor`` is None, + # matching upstream's ``hasNextPage ? (endCursor ?? undefined) + # : undefined`` (here ``end_cursor if hasNextPage and end_cursor is + # not None else None``). Pins the ``and end_cursor is not None`` term + # so a present-cursor regression cannot fabricate a cursor here. + (True, None), + ], + ) + async def test_next_cursor_none_unless_both_has_next_page_and_cursor( + self, has_next_page: bool, end_cursor: str | None + ) -> None: adapter = _make_adapter() - # endCursor is present, but hasNextPage is False → next_cursor is None. - # A regression that returned endCursor regardless of hasNextPage would - # leak ``cursor-stale`` here. adapter._graphql_query = _query_router( # type: ignore[method-assign] _session_return(), - _children_return(has_next_page=False, end_cursor="cursor-stale"), + _children_return(has_next_page=has_next_page, end_cursor=end_cursor), ) result = await adapter.fetch_messages(_SESSION_THREAD) + # next_cursor is set ONLY when BOTH hasNextPage is true AND endCursor is + # present; neither case above satisfies both, so it is None. assert result.next_cursor is None +# =========================================================================== +# Dispatch ordering — comment-session thread routes to the AGENT-SESSION fetch +# =========================================================================== + + +class TestFetchDispatchOrdering: + @pytest.mark.asyncio + async def test_comment_session_thread_dispatches_to_agent_session_fetch(self) -> None: + """A ``linear:{issue}:c:{comment}:s:{session}`` thread id must dispatch to + the AGENT-SESSION fetch, NOT the comment-thread fetch. + + ``fetch_messages`` tests ``decoded.agent_session_id`` BEFORE + ``decoded.comment_id`` (upstream index.ts:1757 — the ``agentSessionId`` + branch precedes the ``commentId`` branch), and a comment-session thread id + decodes to a ``LinearThreadId`` with BOTH ``comment_id`` and + ``agent_session_id`` set. If the agent-session branch were moved BELOW the + ``comment_id`` branch (a branch-swap mutation), this thread would wrongly + route to ``_fetch_comment_thread`` and issue the ``comment(id:)`` query + instead of the two-step ``agentSession``/``comments`` queries — failing the + assertions below. + """ + adapter = _make_adapter() + comment_session_thread = "linear:issue-123:c:comment-root:s:session-789" + # Sanity: the id genuinely decodes to all three segments (so the routing + # decision is a real precedence choice, not a parse artifact). + decoded = adapter.decode_thread_id(comment_session_thread) + assert decoded.agent_session_id == "session-789" + assert decoded.comment_id == "comment-root" + + child = _bot_comment(comment_id="comment-a", body="reply") + adapter._graphql_query = _query_router( # type: ignore[method-assign] + _session_return(session_id="session-789"), + _children_return(nodes=[child]), + ) + + result = await adapter.fetch_messages(comment_session_thread) + + # Two queries ran: the agent-session resolve, then the children connection. + # The comment-thread fetch (``comment(id: $commentId)``) issues exactly ONE. + assert adapter._graphql_query.await_count == 2 + first_query, first_vars = adapter._graphql_query.call_args_list[0][0] + assert "agentSession(id: $id)" in first_query + assert first_vars == {"id": "session-789"} + second_query = adapter._graphql_query.call_args_list[1][0][0] + assert "comments(" in second_query + # The comment-thread query shape MUST NOT appear — proves we did not route + # to ``_fetch_comment_thread``. + assert "comment(id: $commentId)" not in first_query + assert "comment(id: $commentId)" not in second_query + # The session path re-encodes each comment's OWN id plus the :s: segment. + assert [m.thread_id for m in result.messages] == [ + "linear:issue-123:c:comment-root:s:session-789", + "linear:issue-123:c:comment-a:s:session-789", + ] + + +# =========================================================================== +# Null-session guard — message describes the real failure (not "missing issueId") +# =========================================================================== + + +class TestNullSessionGuard: + @pytest.mark.asyncio + async def test_null_session_raises_not_found(self) -> None: + """When the raw-GraphQL ``agentSession(id)`` resolves to ``null`` (the + port-only branch — upstream's SDK throws its own not-found), the guard + must describe the REAL failure: the session was not found, NOT + "missing issueId" (which belongs to the SEPARATE downstream guard that + only fires AFTER a session resolves but yields no issue id). + """ + adapter = _make_adapter() + adapter._graphql_query = AsyncMock(return_value={"data": {"agentSession": None}}) # type: ignore[method-assign] + + with pytest.raises(AdapterError) as exc_info: + await adapter.fetch_messages(_SESSION_THREAD) + message = str(exc_info.value) + assert message == "Linear agent session session-789 not found" + # Guard against the prior misleading wording. + assert "missing issueId" not in message + + +# =========================================================================== +# issueId fallback — nullish (??) NOT truthiness (||): empty issue.id is kept +# =========================================================================== + + +class TestFetchAgentSessionIssueIdNullish: + @pytest.mark.asyncio + async def test_empty_session_issue_id_is_kept_not_replaced_by_thread(self) -> None: + """A session whose ``issue.id`` is an EMPTY STRING must KEEP ``""`` as the + resolved issue id (``session_issue_id if session_issue_id is not None else + thread.issue_id``) — NOT fall back to ``thread.issue_id``. + + Upstream is ``agentSession.issueId ?? thread.issueId`` followed by + ``if (!issueId) throw`` (index.ts:1775-1781): the ``??`` keeps ``""`` and + the ``!issueId`` falsy guard then bails. So with a session ``issue.id`` of + ``""`` and a NON-empty ``thread.issue_id`` fallback, the correct (nullish) + code keeps ``""`` and raises "missing issueId"; if the fallback were + mutated to ``or`` (truthiness), ``""`` would be REPLACED by the non-empty + ``thread.issue_id`` and the fetch would SUCCEED — so this raise is the + unforgeable signal that the empty string was preserved. + + Called directly (a thread id string cannot encode an empty issue id) with + a non-empty ``thread.issue_id`` so the ``or`` mutation is distinguishable + from the ``is not None`` truth. + """ + adapter = _make_adapter() + # Session resolves with an EMPTY issue.id; the children query must never + # run (the falsy guard bails first) — a failing AsyncMock proves that. + adapter._graphql_query = AsyncMock( # type: ignore[method-assign] + side_effect=[ + _session_return(issue_id=""), + AssertionError("children query must not run when issue id is empty"), + ] + ) + thread = LinearAgentSessionThreadId(issue_id="issue-fallback", agent_session_id="session-789") + + with pytest.raises(AdapterError) as exc_info: + await adapter._fetch_agent_session_messages(thread) + # The empty string was kept (per ``??``) and tripped the ``!issueId`` + # guard. Under an ``or`` mutation, ``thread.issue_id`` ("issue-fallback") + # would have been substituted and the fetch would have proceeded. + assert "missing issueId" in str(exc_info.value) + # Only the session query ran; the children query was never reached. + assert adapter._graphql_query.await_count == 1 + + # =========================================================================== # Append-only guards — edit / delete # ===========================================================================