Skip to content

feat(linear): agent-activity emit — post/typing/stream raw GraphQL (chat@4.31/#151 — L4/5)#172

Merged
patrick-chinchill merged 2 commits into
mainfrom
feat/4.31-linear-agent-sessions-l4-emit
Jun 20, 2026
Merged

feat(linear): agent-activity emit — post/typing/stream raw GraphQL (chat@4.31/#151 — L4/5)#172
patrick-chinchill merged 2 commits into
mainfrom
feat/4.31-linear-agent-sessions-l4-emit

Conversation

@patrick-chinchill

Copy link
Copy Markdown
Collaborator

What

Ports the Linear agent-session EMIT path — the 4th of the 5-PR Wave D (#151). Builds on L1 types, L2 thread-id, and L3 webhook-parse (all on main).

The Python LinearAdapter has no @linear/sdk, so upstream's createAgentActivity / updateAgentSession SDK calls are ported as raw GraphQL mutations over the existing _graphql_query helper, schema-hardened against Linear's published GraphQL schema.

Surface (branch-for-branch with adapter-linear/src/index.ts)

  • post_message session branch → agentActivityCreate with content {type:"response", body}_parse_message_from_agent_activity. Comment branch unchanged.
  • start_typing session branch → ephemeral {type:"thought", body: status ?? "Thinking..."}; warn-and-noop for comment threads.
  • stream dispatches to _stream_in_agent_session for session threads (comment-path stream left byte-identical). A StreamingMarkdownRenderer with flush_markdown(type, markdown, force) computing a JS-.trim() delta against last_appended; task_update → error/action activity (ephemeral by status); plan_updateagentSessionUpdate plan; final force-flush → {type:"response"}_parse_message_from_agent_activity.
  • _parse_message_from_agent_activity resolves agentActivity + sourceComment, raising AdapterError (exact upstream strings) on the failure / missing-source-comment paths, then builds the raw message off the comment (user vs botActor author resolution).

GraphQL mutations + enum casing (schema source)

mutation input content enum
activity agentActivityCreate(input: AgentActivityCreateInput!) agentSessionId: String!, content: JSONObject!, ephemeral: Boolean lowercase "response" / "thought" / "error" / "action"
plan agentSessionUpdate(id: String!, input: AgentSessionUpdateInput!) plan: [{content, status:"completed"}]

content is the JSONObject! scalar, so type/body/action/parameter/result are inline JSON fields; ephemeral is a sibling of content, included only when set. The return selection requests only schema-valid fields (success, agentActivity { id agentSessionId sourceComment {…} }).

Source: https://linear.app/developers/agent-interaction, https://linear.app/developers/graphql, and the @linear/sdk generated GraphQL documents. Live-tenant verification pending — documented in docs/UPSTREAM_SYNC.md (the mutation/field names + enum casing are confirmed against the published schema but not yet exercised against a live Linear agent-session tenant).

Faithful-port hazards preserved

  • status ?? "Thinking..."is not None (empty status stays "", NOT or)
  • [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
  • initialize captures the viewer's organization.id into _default_organization_id (mirrors upstream defaultOrganizationId) for the emitted raw message's organizationId

Tests

tests/test_linear_agent_session_emit.py (20 tests): post/typing/stream paths, both AdapterError paths, delta no-op when empty & not forced, task_update error→Error / non-error→Action (ephemeral by status), plan_update→agentSessionUpdate, final force-flush→Response, raise on missing final activity. Each fails on a plausible mutation (verified: enum swap response→thought, ephemeral flip, ??or swap, dropped force-flush). Comment-path post/stream regression stays green.

Scope

EMIT path only. Does not touch the L3 webhook parse, the existing comment-path post/stream (kept byte-identical), or CHANGELOG.md. L5 (agent-session FETCH: get_message / history) is the remaining wave item.

Gauntlet

ruff check + format, audit_test_quality.py (0 hard failures), pyrefly check src/ (0 errors) + test file (0 errors), verify_test_fidelity.py --strict (732/732, pinned chat@4.30.0), pytest (5213 passed, 4 skipped; tests/test_linear*.py 236 passed).

🤖 Generated with Claude Code

…hat@4.31/#151 — L4/5)

Port the Linear agent-session EMIT path (4th of the 5-PR wave D, #151).
Builds on the L1 types, L2 thread-id, and L3 webhook-parse work already on
main. The Python adapter has no `@linear/sdk`, so upstream's
`createAgentActivity` / `updateAgentSession` SDK calls are ported as raw
GraphQL mutations over the existing `_graphql_query` helper, schema-hardened
against Linear's published GraphQL schema.

Surface (branch-for-branch with adapter-linear/src/index.ts):

- `post_message` session branch -> `agentActivityCreate` with content
  `{type:"response", body}` -> `_parse_message_from_agent_activity`.
- `start_typing` session branch -> ephemeral `{type:"thought", body:
  status ?? "Thinking..."}`; warn-and-noop for comment threads.
- `stream` dispatches to `_stream_in_agent_session` for session threads
  (comment path left byte-identical): a `StreamingMarkdownRenderer` with
  `flush_markdown(type, markdown, force)` computing a JS-`.trim()` delta;
  `task_update` -> error/action activity (ephemeral by status);
  `plan_update` -> `agentSessionUpdate` plan; final force-flush ->
  `{type:"response"}` -> `_parse_message_from_agent_activity`.
- `_parse_message_from_agent_activity` resolves `agentActivity` +
  `sourceComment`, raising AdapterError (exact upstream strings) on the
  failure/missing-comment paths, and builds the raw message off the comment
  (user vs botActor author resolution).

Mutations confirmed against the published schema/docs: `agentActivityCreate`
(content as a `JSONObject!` scalar) and `agentSessionUpdate`, with the
LOWERCASE `AgentActivityType` enum (response/thought/error/action).
Live-tenant verification pending — documented in UPSTREAM_SYNC.md.

`initialize` now captures the viewer's `organization.id` into
`_default_organization_id` for the emitted raw message's organizationId.

Faithful hazards preserved: `?? "Thinking..."` -> `is not None`,
`filter(Boolean)` -> truthiness, JS-`.trim()` whitespace set, `if delta or
force`, `ephemeral: status != "complete"`, bare `Error` -> RuntimeError.

Tests: tests/test_linear_agent_session_emit.py — post/typing/stream paths
plus both AdapterError paths; each fails on a plausible mutation (enum swap,
ephemeral flip, nullish->or swap, dropped force-flush). Comment-path
post/stream regression stays green.
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@patrick-chinchill, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 10 minutes and 22 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2cdf067f-5d9d-439a-b9a1-b6ffa01ffedc

📥 Commits

Reviewing files that changed from the base of the PR and between baed3f6 and c06508a.

📒 Files selected for processing (3)
  • docs/UPSTREAM_SYNC.md
  • src/chat_sdk/adapters/linear/adapter.py
  • tests/test_linear_agent_session_emit.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements the agent-activity EMIT path (L4) for Linear agent sessions in the Python SDK, porting the functionality from the TypeScript SDK using raw GraphQL mutations. It introduces helper methods for creating agent activities, parsing messages from activities, and streaming text/chunks within an agent session, alongside comprehensive unit tests. The review feedback highlights opportunities to harden the implementation against potential runtime errors and schema validation failures. Specifically, it suggests using safe fallback navigation (or {}) when extracting GraphQL query results to prevent AttributeErrors, and replacing truthiness checks with explicit is not None checks for optional fields like title and output to ensure falsy but valid values are not silently ignored.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

_AGENT_ACTIVITY_CREATE_MUTATION,
{"input": activity_input},
)
return cast("dict[str, Any]", result.get("data", {}).get("agentActivityCreate", {}))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If the GraphQL response contains "data": null (e.g., due to a top-level query error) or "agentActivityCreate": null, calling .get() on the result will raise an AttributeError. Using or {} instead of the default dict argument in .get() ensures safe fallback navigation and guarantees a dictionary is returned.

Suggested change
return cast("dict[str, Any]", result.get("data", {}).get("agentActivityCreate", {}))
return cast("dict[str, Any]", (result.get("data") or {}).get("agentActivityCreate") or {})

Comment thread src/chat_sdk/adapters/linear/adapter.py Outdated
Comment on lines +1960 to +1962
"action": title,
"parameter": "",
"result": output,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If title or output is None, they will serialize to null in the inline JSON payload. To avoid silently ignoring falsy but valid values (such as 0 or False), use an explicit is not None check instead of a truthiness check to provide an empty string fallback.

Suggested change
"action": title,
"parameter": "",
"result": output,
"action": title if title is not None else "",
"parameter": "",
"result": output if output is not None else "",
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

"input": {
"plan": [
{
"content": _read(chunk, "title"),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

If _read(chunk, "title") returns None, the content field will serialize to null, which may fail GraphQL schema validation. To avoid silently ignoring falsy but valid values, use an explicit is not None check instead of a truthiness check to default to an empty string.

Suggested change
"content": _read(chunk, "title"),
"content": _read(chunk, "title") if _read(chunk, "title") is not None else "",
References
  1. When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use is not None instead of a truthiness check to avoid silently ignoring them.

…d + result-omit (L4 review)

Apply the L4 emit review fix-list:

1. GraphQL selection: agentActivityCreate return selection requested a
   non-existent scalar agentActivity.agentSessionId; Linear's schema exposes
   only the relation agentSession: AgentSession!, so strict selection
   validation server-rejected the whole mutation. Select agentSession { id }.
2. Consumer read (lockstep): _parse_message_from_agent_activity now reads the
   session id None-safely off (activity.get("agentSession") or {}).get("id"),
   keeping the raise-on-missing guard + message.
3. thread_id: _raw_message_from_source_comment encoded the routed thread id
   with the source comment's parentId; upstream parseMessage and the adapter's
   own read-path encode the comment's OWN id. Use comment_data["id"].
4. task_update action wire-shape: omit the result key when output is None
   (upstream passes result: chunk.output string|undefined; JSON.stringify omits
   undefined) instead of serializing "result": null.
5. Doc: correct the UPSTREAM_SYNC.md emit row to the fixed selection and note
   the organizationId "" fallback divergence; keep the live-tenant hedge.

Tests (test_linear_agent_session_emit.py): fix the response fixture to emit
the agentSession relation; add thread_id own-id assertions (post + stream),
split the mislabeled final-flush test into the RuntimeError force-flush branch
and the AdapterError parse-failure branch, add the missing-agentSession
AdapterError case, bot-author nullish-precedence coverage, the _JS_WHITESPACE
NEL-retain / BOM-strip deltas, and the result-omit/include pair. Each
new/changed assertion verified to fail against the corresponding pre-fix code.
@patrick-chinchill patrick-chinchill marked this pull request as ready for review June 20, 2026 07:35
@patrick-chinchill patrick-chinchill merged commit 270b5e0 into main Jun 20, 2026
9 checks passed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c06508a764

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

"""
from chat_sdk.shared.streaming_markdown import StreamingMarkdownRenderer

renderer = StreamingMarkdownRenderer()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Disable append-only table wrapping for Linear activities

When a stream flushes markdown before a task_update after emitting a table, the default StreamingMarkdownRenderer() uses wrap_tables_for_append=True, so get_committable_text() adds an opening code fence and last_appended is measured against that transformed text. The final flush then compares that length to renderer.finish()'s unwrapped markdown, causing content to be dropped (e.g. a table, then a task update, then after sends a final response body of only r) and the earlier Linear activity has an unterminated code fence. Linear activities are separate messages rather than one append-only buffer, so this should use a non-wrapping renderer or otherwise keep the delta coordinate space consistent.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant