feat(linear): agent-activity emit — post/typing/stream raw GraphQL (chat@4.31/#151 — L4/5)#172
Conversation
…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.
|
Warning Review limit reached
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 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 configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
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. Comment |
There was a problem hiding this comment.
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", {})) |
There was a problem hiding this comment.
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.
| return cast("dict[str, Any]", result.get("data", {}).get("agentActivityCreate", {})) | |
| return cast("dict[str, Any]", (result.get("data") or {}).get("agentActivityCreate") or {}) |
| "action": title, | ||
| "parameter": "", | ||
| "result": output, |
There was a problem hiding this comment.
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.
| "action": title, | |
| "parameter": "", | |
| "result": output, | |
| "action": title if title is not None else "", | |
| "parameter": "", | |
| "result": output if output is not None else "", |
References
- When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use
is not Noneinstead of a truthiness check to avoid silently ignoring them.
| "input": { | ||
| "plan": [ | ||
| { | ||
| "content": _read(chunk, "title"), |
There was a problem hiding this comment.
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.
| "content": _read(chunk, "title"), | |
| "content": _read(chunk, "title") if _read(chunk, "title") is not None else "", |
References
- When checking for optional values that can be falsy but valid (e.g., 0, empty string, empty list), use
is not Noneinstead 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.
There was a problem hiding this comment.
💡 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() |
There was a problem hiding this comment.
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 👍 / 👎.
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
LinearAdapterhas no@linear/sdk, so upstream'screateAgentActivity/updateAgentSessionSDK calls are ported as raw GraphQL mutations over the existing_graphql_queryhelper, schema-hardened against Linear's published GraphQL schema.Surface (branch-for-branch with
adapter-linear/src/index.ts)post_messagesession branch →agentActivityCreatewithcontent {type:"response", body}→_parse_message_from_agent_activity. Comment branch unchanged.start_typingsession branch → ephemeral{type:"thought", body: status ?? "Thinking..."}; warn-and-noop for comment threads.streamdispatches to_stream_in_agent_sessionfor session threads (comment-path stream left byte-identical). AStreamingMarkdownRendererwithflush_markdown(type, markdown, force)computing a JS-.trim()delta againstlast_appended;task_update→ error/action activity (ephemeral by status);plan_update→agentSessionUpdateplan; final force-flush →{type:"response"}→_parse_message_from_agent_activity._parse_message_from_agent_activityresolvesagentActivity+sourceComment, raisingAdapterError(exact upstream strings) on the failure / missing-source-comment paths, then builds the raw message off the comment (user vsbotActorauthor resolution).GraphQL mutations + enum casing (schema source)
agentActivityCreate(input: AgentActivityCreateInput!)agentSessionId: String!,content: JSONObject!,ephemeral: Boolean"response"/"thought"/"error"/"action"agentSessionUpdate(id: String!, input: AgentSessionUpdateInput!)plan: [{content, status:"completed"}]contentis theJSONObject!scalar, sotype/body/action/parameter/resultare inline JSON fields;ephemeralis a sibling ofcontent, 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/sdkgenerated GraphQL documents. Live-tenant verification pending — documented indocs/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"", NOTor)[title, output].filter(Boolean).join("\n")→"\n".join(x for x in [title, output] if x)(dropsNoneand"")markdown.slice(...).trim()uses the JS-.trim()whitespace set (_JS_WHITESPACE, mirroringadapters/telegram/rich.py), not Python's broaderstr.strip()if delta or force;ephemeral: status != "complete"; the missing-final-flush barethrow new Error(...)→RuntimeErrorinitializecaptures the viewer'sorganization.idinto_default_organization_id(mirrors upstreamdefaultOrganizationId) for the emitted raw message'sorganizationIdTests
tests/test_linear_agent_session_emit.py(20 tests): post/typing/stream paths, bothAdapterErrorpaths, 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,??→orswap, 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, pinnedchat@4.30.0),pytest(5213 passed, 4 skipped;tests/test_linear*.py236 passed).🤖 Generated with Claude Code