Skip to content

Commit c06508a

Browse files
fix(linear): correct agent-activity emit GraphQL selection + thread_id + 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.
1 parent 63658ee commit c06508a

3 files changed

Lines changed: 265 additions & 15 deletions

File tree

docs/UPSTREAM_SYNC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,7 @@ stay explicit instead of being rediscovered in code review.
685685
| Teams `graph` path-segment encoding (vercel/chat#8c71411, chat@4.31) | Path segments (`team_id` / `channel_id` / `chat_id` / `message_id`) are interpolated through `quote(segment, safe='')` | Upstream `graph/{channels,messages}.ts` use `encodeURIComponent(...)` | Benign over-encoding divergence. `quote(safe='')` percent-encodes a strictly larger set than `encodeURIComponent` (notably `!`, `'`, `(`, `)`, `*` — which `encodeURIComponent` leaves literal). Graph IDs (team/channel/chat/message GUIDs and thread tokens) never contain those characters, so the encoded path is identical in practice; where they did differ, the stricter `quote` is the safer choice (no URL injection via an unescaped sub-delimiter). Documented rather than narrowed to match `encodeURIComponent` exactly. |
686686
| 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). |
687687
| `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`. |
688-
| 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 agentSessionId sourceComment { id body parentId createdAt updatedAt url user{…} botActor{…} } }`). `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`. | 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`. |
688+
| 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`. |
689689
### Platform-specific gaps
690690

691691
| Area | Python | TS | Rationale |

src/chat_sdk/adapters/linear/adapter.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,13 +137,20 @@
137137
# "error"/"action"), ``agentSessionId: String!`` and ``ephemeral: Boolean``. The
138138
# return selection requests only schema-valid fields that
139139
# ``_parse_message_from_agent_activity`` reads off the resolved ``sourceComment``.
140+
# NOTE: ``agentSessionId`` is NOT a scalar field on Linear's ``AgentActivity``
141+
# type — the schema exposes only the relation ``agentSession: AgentSession!``.
142+
# GraphQL strict-validates selections, so requesting a non-existent
143+
# ``agentSessionId`` field server-rejects the whole mutation; select the
144+
# ``agentSession { id }`` relation instead.
140145
_AGENT_ACTIVITY_CREATE_MUTATION = """
141146
mutation AgentActivityCreate($input: AgentActivityCreateInput!) {
142147
agentActivityCreate(input: $input) {
143148
success
144149
agentActivity {
145150
id
146-
agentSessionId
151+
agentSession {
152+
id
153+
}
147154
sourceComment {
148155
id
149156
body
@@ -1253,7 +1260,11 @@ async def _parse_message_from_agent_activity(
12531260
"linear",
12541261
)
12551262

1256-
activity_session_id = activity.get("agentSessionId")
1263+
# The mutation selects the ``agentSession { id }`` relation (NOT a
1264+
# non-existent scalar ``agentSessionId`` field — see
1265+
# ``_AGENT_ACTIVITY_CREATE_MUTATION``); read the session id None-safely
1266+
# off the nested relation node.
1267+
activity_session_id = (activity.get("agentSession") or {}).get("id")
12571268
if not activity_session_id:
12581269
raise AdapterError(
12591270
f"Missing agentSessionId for Linear agent activity {activity.get('id')}",
@@ -1354,10 +1365,13 @@ def _raw_message_from_source_comment(
13541365
# re-derived on read via :meth:`_parse_agent_session_message`. Encode the
13551366
# routed thread id (carrying the ``:s:{session}`` segment) so callers can
13561367
# round-trip it, matching the comment branch's ``thread_id`` semantics.
1368+
# Upstream ``parseMessage`` (index.ts:2033-2038) and the adapter's own
1369+
# read-path :meth:`_parse_agent_session_message` encode the source
1370+
# comment's OWN id (``commentId: raw.comment.id``) — NOT its parentId.
13571371
thread_id = self.encode_thread_id(
13581372
LinearThreadId(
13591373
issue_id=issue_id,
1360-
comment_id=cast("str | None", comment_data.get("parentId")),
1374+
comment_id=cast("str | None", comment_data.get("id")),
13611375
agent_session_id=agent_session_id,
13621376
)
13631377
)
@@ -1953,14 +1967,21 @@ def _read(chunk: Any, name: str) -> Any:
19531967
else:
19541968
# ``ephemeral: status !== "complete"`` — in-progress/pending
19551969
# actions are ephemeral; only a completed action persists.
1970+
# Upstream passes ``result: chunk.output`` (string|undefined);
1971+
# ``JSON.stringify`` OMITS a key whose value is undefined, so
1972+
# OMIT ``result`` entirely when ``output`` is None to match
1973+
# the key-absent wire shape (rather than serializing
1974+
# ``"result": null``).
1975+
action_content: dict[str, Any] = {
1976+
"type": "action",
1977+
"action": title,
1978+
"parameter": "",
1979+
}
1980+
if output is not None:
1981+
action_content["result"] = output
19561982
await self._create_agent_activity(
19571983
agent_session_id,
1958-
{
1959-
"type": "action",
1960-
"action": title,
1961-
"parameter": "",
1962-
"result": output,
1963-
},
1984+
action_content,
19641985
ephemeral=status != "complete",
19651986
)
19661987
continue

0 commit comments

Comments
 (0)