From 9d0f2a046126df661e306b3ba000804842231d0e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 23:12:56 +0000 Subject: [PATCH 01/40] sync: upstream Vercel Chat v4.26.0 Ports four upstream commits (chat@4.25.0..chat@4.26.0): - fix(chat): guard fallback streaming against empty content (#357) Thread._fallback_stream() no longer edits or posts empty content during the LLM warm-up or when a chunk buffers to whitespace. With the placeholder disabled and an empty stream, posts a single space so the stream still returns a SentMessage. - feat(slack): empty header cells render as space in native table block (#373) - Replaces `or " "` truthiness fallback with an explicit length check so "0"-like strings are not replaced. - fix(gchat): render markdown links with custom-label syntax (#356) - `[Click here](https://example.com)` now renders as `` instead of `Click here (https://...)`. - fix(chat): export standalone `reviver` for workflow-safe deserialization (#257) - New top-level chat_sdk.reviver lets Vercel Workflow functions deserialize Thread/Channel/Message without pulling adapter dependencies through the Chat instance. Thread.to_json()/Channel.to_json() now prefer the stored _adapter_name so objects revived without a singleton can still re-serialize. Python usage: json.loads(payload, object_hook=reviver). Also: - UPSTREAM_PARITY = "4.26.0" - Version bumped to 0.4.26 - README/CLAUDE.md/CHANGELOG.md updated - Existing fallback-streaming tests updated to reflect the new empty-content semantics (no more empty intermediate posts/edits) --- CHANGELOG.md | 13 + CLAUDE.md | 2 +- README.md | 2 +- pyproject.toml | 2 +- src/chat_sdk/__init__.py | 5 +- .../adapters/google_chat/format_converter.py | 6 +- .../adapters/slack/format_converter.py | 16 +- src/chat_sdk/channel.py | 2 +- src/chat_sdk/reviver.py | 52 ++++ src/chat_sdk/thread.py | 20 +- tests/test_chat_faithful.py | 11 +- tests/test_gchat_format_extended.py | 10 +- tests/test_serialization.py | 227 ++++++++++++++++++ tests/test_slack_format.py | 27 +++ tests/test_thread_faithful.py | 72 ++++-- 15 files changed, 422 insertions(+), 45 deletions(-) create mode 100644 src/chat_sdk/reviver.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f920282..5667ed9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.4.26 (2026-04-16) + +Synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat). + +### New features (from upstream 4.26.0) +- **Standalone `reviver`**: new top-level `chat_sdk.reviver` function for deserializing `Thread`, `Channel`, and `Message` objects without importing a `Chat` instance. Designed for Vercel Workflow step functions and any environment where pulling adapter dependencies is undesirable. Use it as `json.loads(payload, object_hook=reviver)`. Lazy adapter resolution: `chat.register_singleton()` / `chat.activate()` must still be called before thread methods like `post()` are invoked. +- **Workflow-safe `to_json()`**: `Thread.to_json()` and `Channel.to_json()` now prefer the stored `_adapter_name` over `self.adapter.name`, so objects revived without a singleton can still be re-serialized. + +### Fixes (from upstream 4.26.0) +- **Fallback streaming no longer edits/posts empty content**: `Thread.post(stream)` on adapters without native streaming no longer sends `{markdown: ""}` during the LLM warm-up or when a chunk buffers to whitespace. Empty streams with placeholders disabled now post a single space rather than an empty string (a non-empty `SentMessage` is required by the stream contract). +- **Slack empty header cells**: Markdown tables with an empty header cell now render as a single space in the Slack table block instead of being rejected by the Slack API. Replaces a truthiness-based fallback with an explicit length check, matching upstream. +- **Google Chat custom link labels**: `[Click here](https://example.com)` now renders as `` (Google Chat's supported custom-label syntax) instead of `Click here (https://example.com)`. + ## 0.4.25 (2026-04-10) Synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat). New versioning: `0.{upstream_major}.{upstream_minor}` embeds the upstream version directly. diff --git a/CLAUDE.md b/CLAUDE.md index dbe691e..7b3800c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # Claude Code Quick Reference -- chat-sdk-python ## What is this? -Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (v4.25.0). Multi-platform async chat framework. +Python port of [Vercel Chat SDK](https://github.com/vercel/chat) (v4.26.0). Multi-platform async chat framework. ## Key Commands ```bash diff --git a/README.md b/README.md index a72a714..56c7749 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Multi-platform async chat SDK for Python. Port of [Vercel Chat](https://github.com/vercel/chat). -> **Status: Alpha (0.4.25 — synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat))** — API may change. +> **Status: Alpha (0.4.26 — synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat))** — API may change. ## Why chat-sdk? diff --git a/pyproject.toml b/pyproject.toml index fb0dec4..2c9fbfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "chat-sdk" -version = "0.4.25" +version = "0.4.26" description = "Multi-platform async chat SDK for Python — port of Vercel Chat" readme = "README.md" license = {text = "MIT"} diff --git a/src/chat_sdk/__init__.py b/src/chat_sdk/__init__.py index 85cf6b3..d911a99 100644 --- a/src/chat_sdk/__init__.py +++ b/src/chat_sdk/__init__.py @@ -101,6 +101,7 @@ is_postable_object, post_postable_object, ) +from chat_sdk.reviver import reviver from chat_sdk.shared.base_format_converter import BaseFormatConverter from chat_sdk.shared.errors import ( AdapterError, @@ -181,7 +182,7 @@ ) # The upstream Vercel Chat version this release is synced to. -UPSTREAM_PARITY = "4.25.0" +UPSTREAM_PARITY = "4.26.0" __all__ = [ "UPSTREAM_PARITY", @@ -205,6 +206,8 @@ "post_postable_object", # AI "to_ai_messages", + # Standalone reviver for workflow-safe deserialization + "reviver", # Card builders (PascalCase primary — matches source TS SDK) "Actions", "Button", diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 57bb441..7982204 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -101,15 +101,13 @@ def _node_to_gchat(self, node: Content) -> str: return f"```\n{node.get('value', '')}\n```" if node_type == "link": - # Google Chat auto-detects links, so we just output the URL + # Google Chat supports custom link labels using syntax. children = node.get("children", []) link_text = "".join(self._node_to_gchat(child) for child in children) url = node.get("url", "") - # If link text matches URL, just output URL if link_text == url: return url - # Otherwise output "text (url)" - return f"{link_text} ({url})" + return f"<{url}|{link_text}>" if node_type == "heading": # Intentional improvement over TS SDK: Google Chat has no heading diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index 2552a8c..6e8e5b6 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -280,13 +280,15 @@ def _mdast_table_to_slack_block(self, node: Content) -> SlackBlock: rows_data: list[list[dict[str, str]]] = [] for row in node.get("children", []): - cells = [ - { - "type": "raw_text", - "text": "".join(self._node_to_mrkdwn(c) for c in cell.get("children", [])) or " ", - } - for cell in row.get("children", []) - ] + cells = [] + for cell in row.get("children", []): + # Convert cell children to text, defaulting to a space if empty. + # Slack API requires table cell text to be at least 1 character. + # Use an explicit length check rather than a truthiness check to + # avoid substituting valid strings like "0". + raw_text = "".join(self._node_to_mrkdwn(c) for c in cell.get("children", [])) + text = raw_text if len(raw_text) > 0 else " " + cells.append({"type": "raw_text", "text": text}) rows_data.append(cells) block: SlackBlock = {"type": "table", "rows": rows_data} diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 7a163d1..02e4187 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -389,7 +389,7 @@ def to_json(self) -> dict[str, Any]: return { "_type": "chat:Channel", "id": self._id, - "adapterName": self.adapter.name, + "adapterName": self._adapter_name or self.adapter.name, "channelVisibility": self._channel_visibility, "isDM": self._is_dm, } diff --git a/src/chat_sdk/reviver.py b/src/chat_sdk/reviver.py new file mode 100644 index 0000000..7ce7d18 --- /dev/null +++ b/src/chat_sdk/reviver.py @@ -0,0 +1,52 @@ +"""Standalone JSON reviver for Chat SDK objects. + +Restores serialized Thread, Channel, and Message instances during +``json.loads(... object_hook=...)`` or :func:`rehydrate` without requiring a +:class:`~chat_sdk.chat.Chat` instance. This is useful in environments such as +Vercel Workflow functions where importing the full Chat instance (with its +adapter dependencies) is not possible. + +Thread and Channel instances created this way use lazy adapter resolution — +the adapter is looked up from the Chat singleton when first accessed, so +``chat.register_singleton()`` (or ``chat.activate()``) must be called before +using methods like :meth:`Thread.post` (typically inside a step function). + +Usage +----- +Python's ``json.loads`` has no direct equivalent of JS's ``JSON.parse`` +reviver, but the same effect is achieved with ``object_hook``:: + + import json + from chat_sdk import reviver + + data = json.loads(payload, object_hook=reviver) + await data["thread"].post("Hello from workflow!") + +The function itself accepts a single dict and returns either the revived +object or the dict unchanged, matching the ``object_hook`` contract. +""" + +from __future__ import annotations + +from typing import Any + +from chat_sdk.channel import ChannelImpl +from chat_sdk.thread import ThreadImpl +from chat_sdk.types import Message + + +def reviver(value: Any) -> Any: + """Revive a Chat SDK object from its serialized dict representation. + + Compatible with :func:`json.loads` ``object_hook``. Returns ``value`` + unchanged for dicts without a recognized ``_type`` discriminator. + """ + if isinstance(value, dict) and "_type" in value: + t = value["_type"] + if t == "chat:Thread": + return ThreadImpl.from_json(value) + if t == "chat:Channel": + return ChannelImpl.from_json(value) + if t == "chat:Message": + return Message.from_json(value) + return value diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index ef855fe..9c21bde 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -622,7 +622,7 @@ async def _edit_loop() -> None: if stop_event.is_set() or msg is None: break content = renderer.get_committable_text() - if content != last_edit_content: + if content.strip() and content != last_edit_content: try: await self.adapter.edit_message( thread_id_for_edits, @@ -642,10 +642,11 @@ async def _edit_loop() -> None: renderer.push(chunk) if msg is None: content = renderer.get_committable_text() - msg = await self.adapter.post_message(self._id, PostableMarkdown(markdown=content)) - thread_id_for_edits = msg.thread_id or self._id - last_edit_content = content - pending_edit = asyncio.create_task(_edit_loop()) + if content.strip(): + msg = await self.adapter.post_message(self._id, PostableMarkdown(markdown=content)) + thread_id_for_edits = msg.thread_id or self._id + last_edit_content = content + pending_edit = asyncio.create_task(_edit_loop()) finally: stop_event.set() @@ -657,14 +658,17 @@ async def _edit_loop() -> None: # Final message if msg is None: - msg = await self.adapter.post_message(self._id, PostableMarkdown(markdown=accumulated)) + # Stream contract requires a SentMessage, so post at least a space + # if the stream produced only whitespace. + markdown = accumulated if accumulated.strip() else " " + msg = await self.adapter.post_message(self._id, PostableMarkdown(markdown=markdown)) thread_id_for_edits = msg.thread_id or self._id last_edit_content = accumulated # Always ensure the final content is sent, regardless of what _edit_loop did. # Re-check last_edit_content after awaiting pending_edit since _edit_loop # may have updated it concurrently. - if final_content != last_edit_content: + if final_content.strip() and final_content != last_edit_content: await self.adapter.edit_message( thread_id_for_edits, msg.id, @@ -717,7 +721,7 @@ def to_json(self) -> dict[str, Any]: "channelVisibility": self._channel_visibility, "currentMessage": self._current_message.to_json() if self._current_message else None, "isDM": self._is_dm, - "adapterName": self.adapter.name, + "adapterName": self._adapter_name or self.adapter.name, } @classmethod diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 53452b6..5d676c4 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -344,11 +344,12 @@ async def _stream(): # No placeholder "..." should have been posted for _tid, content in adapter._post_calls: assert content != "..." - # The last edit should contain "Hi" - assert len(adapter._edit_calls) > 0 - last_edit = adapter._edit_calls[-1] - # last_edit is (thread_id, message_id, content) - assert last_edit[0] == "slack:C123:1234.5678" + # With placeholder=None and no-newline chunks, the post-4.26 empty-content + # guard defers the first commit until stream end, so the final content + # arrives via post rather than an intermediate edit. + assert len(adapter._post_calls) >= 1 + last_post = adapter._post_calls[-1] + assert last_post[0] == "slack:C123:1234.5678" # ============================================================================ diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index e32adbc..da4d615 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -105,7 +105,15 @@ def test_link_different_text_and_url(self): converter = _converter() ast = converter.to_ast("[click here](https://example.com)") result = converter.from_ast(ast) - assert "click here (https://example.com)" in result + assert "" in result + + def test_should_preserve_custom_link_labels_in_posted_messages(self): + # Matches the integration-tests parity case: a posted markdown link + # with a custom label must render as Google Chat's syntax + # rather than being flattened to "text (url)". + converter = _converter() + result = converter.from_markdown("[Click here](https://example.com)") + assert result == "" def test_blockquote(self): converter = _converter() diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 4361167..da8fe18 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -862,6 +862,233 @@ def test_should_work_with_nested_structures(self, mock_adapter, mock_state): clear_chat_singleton() +# ============================================================================ +# Standalone reviver (no Chat instance required at import time) +# ============================================================================ + + +class TestStandaloneReviver: + """Tests for the module-level :func:`chat_sdk.reviver` function. + + Mirrors the TS ``standalone reviver()`` describe block. Python's + ``json.loads`` uses ``object_hook`` rather than a key/value reviver, so + usage differs slightly: the function is passed as ``object_hook`` and + receives each decoded dict. + """ + + def test_should_revive_chatthread_objects(self, mock_adapter, mock_state): + from chat_sdk import reviver + from chat_sdk.chat import Chat + from chat_sdk.thread import clear_chat_singleton + + chat = Chat( + user_name="test-bot", + adapters={"slack": mock_adapter}, + state=mock_state, + logger="silent", + ) + chat.register_singleton() + try: + payload = json.dumps( + { + "thread": { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channelId": "C123", + "isDM": False, + "adapterName": "slack", + } + } + ) + parsed = json.loads(payload, object_hook=reviver) + assert isinstance(parsed["thread"], ThreadImpl) + assert parsed["thread"].id == "slack:C123:1234.5678" + finally: + clear_chat_singleton() + + def test_should_revive_chatmessage_objects(self, mock_adapter, mock_state): + from chat_sdk import reviver + from chat_sdk.chat import Chat + from chat_sdk.thread import clear_chat_singleton + + chat = Chat( + user_name="test-bot", + adapters={"slack": mock_adapter}, + state=mock_state, + logger="silent", + ) + chat.register_singleton() + try: + payload = json.dumps( + { + "message": { + "_type": "chat:Message", + "id": "msg-1", + "threadId": "slack:C123:1234.5678", + "text": "Hello", + "formatted": {"type": "root", "children": []}, + "raw": {}, + "author": { + "userId": "U123", + "userName": "testuser", + "fullName": "Test User", + "isBot": False, + "isMe": False, + }, + "metadata": { + "dateSent": "2024-01-15T10:30:00.000Z", + "edited": False, + }, + "attachments": [], + } + } + ) + parsed = json.loads(payload, object_hook=reviver) + assert parsed["message"].id == "msg-1" + assert isinstance(parsed["message"].metadata.date_sent, datetime) + finally: + clear_chat_singleton() + + def test_should_revive_both_thread_and_message_in_same_payload(self, mock_adapter, mock_state): + from chat_sdk import reviver + from chat_sdk.chat import Chat + from chat_sdk.thread import clear_chat_singleton + + chat = Chat( + user_name="test-bot", + adapters={"slack": mock_adapter}, + state=mock_state, + logger="silent", + ) + chat.register_singleton() + try: + payload = json.dumps( + { + "thread": { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channelId": "C123", + "isDM": False, + "adapterName": "slack", + }, + "message": { + "_type": "chat:Message", + "id": "msg-1", + "threadId": "slack:C123:1234.5678", + "text": "Hello", + "formatted": {"type": "root", "children": []}, + "raw": {}, + "author": { + "userId": "U123", + "userName": "testuser", + "fullName": "Test User", + "isBot": False, + "isMe": False, + }, + "metadata": { + "dateSent": "2024-01-15T10:30:00.000Z", + "edited": False, + }, + "attachments": [], + }, + } + ) + parsed = json.loads(payload, object_hook=reviver) + assert isinstance(parsed["thread"], ThreadImpl) + assert isinstance(parsed["message"].metadata.date_sent, datetime) + finally: + clear_chat_singleton() + + def test_should_leave_nonchat_objects_unchanged(self, mock_adapter, mock_state): + from chat_sdk import reviver + + payload = json.dumps( + { + "name": "test", + "count": 42, + "nested": {"_type": "other:Type", "value": "unchanged"}, + } + ) + parsed = json.loads(payload, object_hook=reviver) + assert parsed["name"] == "test" + assert parsed["count"] == 42 + assert parsed["nested"]["_type"] == "other:Type" + + def test_should_be_usable_directly_as_json_parse_second_argument(self, mock_adapter, mock_state): + from chat_sdk import reviver + from chat_sdk.chat import Chat + from chat_sdk.thread import clear_chat_singleton + + chat = Chat( + user_name="test-bot", + adapters={"slack": mock_adapter}, + state=mock_state, + logger="silent", + ) + chat.register_singleton() + try: + message_json = { + "_type": "chat:Message", + "id": "msg-direct", + "threadId": "slack:C123:1234.5678", + "text": "Direct usage", + "formatted": {"type": "root", "children": []}, + "raw": {}, + "author": { + "userId": "U123", + "userName": "testuser", + "fullName": "Test User", + "isBot": False, + "isMe": False, + }, + "metadata": { + "dateSent": "2024-01-15T10:30:00.000Z", + "edited": False, + }, + "attachments": [], + } + parsed = json.loads(json.dumps(message_json), object_hook=reviver) + assert parsed.id == "msg-direct" + assert parsed.text == "Direct usage" + assert isinstance(parsed.metadata.date_sent, datetime) + finally: + clear_chat_singleton() + + def test_should_allow_reserialization_of_a_revived_thread_without_singleton(self): + from chat_sdk.thread import clear_chat_singleton + + clear_chat_singleton() + data = { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channelId": "C123", + "isDM": False, + "adapterName": "slack", + } + thread = ThreadImpl.from_json(data) + reserialized = thread.to_json() + assert reserialized["_type"] == "chat:Thread" + assert reserialized["adapterName"] == "slack" + assert reserialized["id"] == "slack:C123:1234.5678" + + def test_should_allow_reserialization_of_a_revived_channel_without_singleton(self): + from chat_sdk.channel import ChannelImpl + from chat_sdk.thread import clear_chat_singleton + + clear_chat_singleton() + data = { + "_type": "chat:Channel", + "id": "C123", + "isDM": False, + "adapterName": "slack", + } + channel = ChannelImpl.from_json(data) + reserialized = channel.to_json() + assert reserialized["_type"] == "chat:Channel" + assert reserialized["adapterName"] == "slack" + assert reserialized["id"] == "C123" + + # ============================================================================ # @workflow/serde integration — ThreadImpl # ============================================================================ diff --git a/tests/test_slack_format.py b/tests/test_slack_format.py index b8ba33d..438da76 100644 --- a/tests/test_slack_format.py +++ b/tests/test_slack_format.py @@ -216,6 +216,33 @@ def test_second_table_falls_back_to_ascii(self): assert blocks[1]["type"] == "section" assert "```" in blocks[1]["text"]["text"] + def test_should_replace_empty_table_cells_with_a_space_to_satisfy_slack_api(self): + ast = self.converter.to_ast("| Kind | Label |\n|------|-------|\n| FORM | Form Submission |\n| and more... | |") + blocks = self.converter.to_blocks_with_table(ast) + assert blocks is not None + table_block = blocks[0] + assert table_block["type"] == "table" + for row in table_block["rows"]: + for cell in row: + assert len(cell["text"]) > 0 + assert table_block["rows"][2][1]["text"] == " " + + def test_should_handle_empty_header_cells_with_parse_markdown_production_path(self): + from chat_sdk.shared.markdown_parser import parse_markdown + + markdown = "Here is a table:\n\n| | Header2 |\n|---------|----------|\n| Data1 | Data2 |" + ast = parse_markdown(markdown) + blocks = self.converter.to_blocks_with_table(ast) + assert blocks is not None + assert len(blocks) == 2 + assert blocks[0]["type"] == "section" + assert blocks[1]["type"] == "table" + table_block = blocks[1] + assert table_block["rows"][0][0]["text"] == " " + for row in table_block["rows"]: + for cell in row: + assert len(cell["text"]) > 0 + # --------------------------------------------------------------------------- # Nested lists diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 81e59da..f5cd8b3 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -367,12 +367,8 @@ async def test_should_handle_empty_stream_with_disabled_placeholder(self): # Should post initial placeholder assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") - # Should edit with empty string wrapped as markdown (final content) - last_edit = adapter._edit_calls[-1] - assert last_edit[0] == "slack:C123:1234.5678" - assert last_edit[1] == "msg-1" - assert isinstance(last_edit[2], PostableMarkdown) - assert last_edit[2].markdown == "" + # Should not edit with empty content + assert len(adapter._edit_calls) == 0 # it("should support disabling the placeholder for fallback streaming") @pytest.mark.asyncio @@ -387,10 +383,12 @@ async def test_should_support_disabling_the_placeholder_for_fallback_streaming(s # Should NOT have posted "..." placeholder_calls = [c for c in adapter._post_calls if c[1] == "..."] assert len(placeholder_calls) == 0 - # Should have final edit with "Hi" - last_edit = adapter._edit_calls[-1] - assert isinstance(last_edit[2], PostableMarkdown) - assert last_edit[2].markdown == "Hi" + # Final content is delivered through post since no mid-stream commit + # fired (no newline in the chunks) and the empty-content guard + # prevents an intermediate empty post. + last_post = adapter._post_calls[-1] + assert isinstance(last_post[1], PostableMarkdown) + assert last_post[1].markdown == "Hi" # it("should handle empty stream with disabled placeholder") @pytest.mark.asyncio @@ -402,14 +400,55 @@ async def test_should_handle_empty_stream(self): text_stream = _create_text_stream([]) await thread.post(text_stream) - # Should still post a message (empty), wrapped as markdown + # Should post a non-empty fallback since stream must return a SentMessage assert len(adapter._post_calls) == 1 posted = adapter._post_calls[0][1] assert isinstance(posted, PostableMarkdown) - assert posted.markdown == "" - # No edit needed since post content matches accumulated + assert posted.markdown == " " assert len(adapter._edit_calls) == 0 + # it("should not post empty content when table is buffered with null placeholder") + @pytest.mark.asyncio + async def test_should_not_post_empty_content_when_table_is_buffered_with_null_placeholder(self): + adapter = create_mock_adapter() + state = create_mock_state() + + thread = _make_thread(adapter, state, fallback_streaming_placeholder_text=None) + text_stream = _create_text_stream(["| A | B |\n", "|---|---|\n", "| 1 | 2 |\n"]) + await thread.post(text_stream) + + for _, content in adapter._post_calls: + if isinstance(content, PostableMarkdown): + assert len(content.markdown.strip()) > 0 + + # it("should not edit placeholder to empty during LLM warm-up") + @pytest.mark.asyncio + async def test_should_not_edit_placeholder_to_empty_during_llm_warmup(self): + adapter = create_mock_adapter() + state = create_mock_state() + + thread = _make_thread(adapter, state) + text_stream = _create_text_stream(["Hello world"]) + await thread.post(text_stream) + + for _, _, content in adapter._edit_calls: + if isinstance(content, PostableMarkdown): + assert len(content.markdown.strip()) > 0 + + # it("should not post empty content during streaming with whitespace chunks") + @pytest.mark.asyncio + async def test_should_not_post_empty_content_during_streaming_with_whitespace_chunks(self): + adapter = create_mock_adapter() + state = create_mock_state() + + thread = _make_thread(adapter, state, fallback_streaming_placeholder_text=None) + text_stream = _create_text_stream([" ", "\n", " \n"]) + await thread.post(text_stream) + + for _, content in adapter._post_calls: + if isinstance(content, PostableMarkdown): + assert len(content.markdown) > 0 + # it("should preserve newlines in streamed text (native path)") @pytest.mark.asyncio async def test_should_preserve_newlines_in_streamed_text_native_path(self): @@ -608,9 +647,12 @@ async def failing_edit(thread_id: str, message_id: str, message: Any) -> RawMess thread = _make_thread(adapter, state, streaming_update_interval_ms=10, logger=logger) async def slow_stream() -> AsyncIterator[str]: - yield "Hel" + # Newlines are required so the streaming renderer commits content + # mid-stream; without them the post-4.26 empty-content guard + # skips intermediate edits entirely. + yield "Hel\n" await asyncio.sleep(0.05) - yield "lo" + yield "lo\n" await thread.post(slow_stream()) From f932f269acff642e1fc40a19ae5ab0ee3f97179c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 23:40:41 +0000 Subject: [PATCH 02/40] fix(thread): clear fallback placeholder on whitespace-only streams Upstream 4.26 guards against empty edits in fallback streaming, which avoids platform APIs rejecting empty bodies but leaves the "..." placeholder stranded on the message forever when a stream produces no real content. We diverge by issuing one final edit_message(" ") when the placeholder is still the latest visible content and the stream produced only whitespace. This mirrors the behavior of the no-placeholder branch (which already posts " " for empty streams) and eliminates the SentMessage.text-vs-rendered- content mismatch that was surfaced in review. Documented in docs/UPSTREAM_SYNC.md under Known Non-Parity. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/UPSTREAM_SYNC.md | 1 + src/chat_sdk/thread.py | 12 ++++++++++++ tests/test_thread_faithful.py | 26 ++++++++++++++++++++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 80a3f25..6fb870b 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -394,6 +394,7 @@ stay explicit instead of being rediscovered in code review. | Chat resolver | 3-level: explicit → ContextVar → global | Process-global singleton | See [DECISIONS.md](DECISIONS.md#why-3-level-chat-resolver) | | PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history | | Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers | +| Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | ### Platform-specific gaps diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 9c21bde..f96f43a 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -674,6 +674,18 @@ async def _edit_loop() -> None: msg.id, PostableMarkdown(markdown=final_content), ) + elif placeholder_text is not None and not final_content.strip() and last_edit_content == placeholder_text: + # Divergence from upstream 4.26: upstream leaves the placeholder + # visible when the stream produces only whitespace, which strands + # "..." on the message forever. We replace it with " " so the + # placeholder is cleared consistently with the no-placeholder branch + # (which also posts " " in this case). See docs/UPSTREAM_SYNC.md. + await self.adapter.edit_message( + thread_id_for_edits, + msg.id, + PostableMarkdown(markdown=" "), + ) + last_edit_content = " " sent = self._create_sent_message( msg.id, diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index f5cd8b3..631f16b 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -367,8 +367,11 @@ async def test_should_handle_empty_stream_with_disabled_placeholder(self): # Should post initial placeholder assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") - # Should not edit with empty content - assert len(adapter._edit_calls) == 0 + # Python divergence: clear the placeholder with " " on empty streams so + # users don't see a stuck "..." forever. (Upstream leaves it visible; + # documented in docs/UPSTREAM_SYNC.md.) + assert len(adapter._edit_calls) == 1 + assert adapter._edit_calls[0][2] == PostableMarkdown(markdown=" ") # it("should support disabling the placeholder for fallback streaming") @pytest.mark.asyncio @@ -390,6 +393,25 @@ async def test_should_support_disabling_the_placeholder_for_fallback_streaming(s assert isinstance(last_post[1], PostableMarkdown) assert last_post[1].markdown == "Hi" + # Python-specific regression: ensure whitespace-only streams don't leave + # the placeholder stuck on the message. This is a deliberate divergence + # from upstream 4.26, which keeps the placeholder visible. + @pytest.mark.asyncio + async def test_should_clear_placeholder_when_stream_is_whitespace_only(self): + adapter = create_mock_adapter() + state = create_mock_state() + + thread = _make_thread(adapter, state) + text_stream = _create_text_stream([" ", "\n", " \n"]) + await thread.post(text_stream) + + # Placeholder was posted + assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") + # And cleared via edit to " " + final_edit = adapter._edit_calls[-1] + assert isinstance(final_edit[2], PostableMarkdown) + assert final_edit[2].markdown == " " + # it("should handle empty stream with disabled placeholder") @pytest.mark.asyncio async def test_should_handle_empty_stream(self): From 0ff1703e2f416994b0155bfcd605cdd4cb29dbe0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 23:49:04 +0000 Subject: [PATCH 03/40] docs: note placeholder-clear divergence in CHANGELOG and ARCHITECTURE The fallback-streaming placeholder fix was added in f932f26 but only recorded in UPSTREAM_SYNC.md. Surface it in the two other places developers look: the CHANGELOG under 0.4.26, and ARCHITECTURE.md near the fallback streaming description. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CHANGELOG.md | 3 +++ docs/ARCHITECTURE.md | 2 ++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5667ed9..2474052 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ Synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat). - **Slack empty header cells**: Markdown tables with an empty header cell now render as a single space in the Slack table block instead of being rejected by the Slack API. Replaces a truthiness-based fallback with an explicit length check, matching upstream. - **Google Chat custom link labels**: `[Click here](https://example.com)` now renders as `` (Google Chat's supported custom-label syntax) instead of `Click here (https://example.com)`. +### Python-specific (divergence from upstream 4.26) +- **Fallback streaming clears stranded placeholders**: when a stream produces only whitespace with the default placeholder enabled, the final edit replaces `"..."` with `" "` so the message doesn't render as permanently loading. Upstream 4.26 intentionally leaves the placeholder visible to avoid empty-edit API calls; we issue one final edit to `" "` instead. Documented under [Known Non-Parity](docs/UPSTREAM_SYNC.md#known-non-parity-with-typescript-sdk). + ## 0.4.25 (2026-04-10) Synced to [Vercel Chat 4.25.0](https://github.com/vercel/chat). New versioning: `0.{upstream_major}.{upstream_minor}` embeds the upstream version directly. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3ca0636..bcd7fcc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -372,3 +372,5 @@ When an adapter does not support native streaming, `ThreadImpl._fallback_stream( 4. After stream ends, stop the edit loop and send a final edit with the complete text The edit loop uses `asyncio.create_task()` for the background timer and checks a `stopped` flag to terminate cleanly. + +Empty/whitespace-only content is guarded throughout: intermediate edits with empty content are skipped (platforms reject empty bodies), and if the final content is whitespace-only the placeholder is cleared to `" "` rather than left stranded. This is a small divergence from upstream 4.26 — see [Known Non-Parity](UPSTREAM_SYNC.md#known-non-parity-with-typescript-sdk). From 6e99c8342ee0241463fec24cf448307f0ecb8155 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 23:59:16 +0000 Subject: [PATCH 04/40] fix(reviver): make from_json idempotent for object_hook use Codex identified that ``json.loads(..., object_hook=reviver)`` revives nested dicts bottom-up, so by the time ``ThreadImpl.from_json`` runs, a payload's ``currentMessage`` is already a ``Message`` instance, not a dict. The old code then called ``Message.from_json(current_msg_raw)`` on that Message, which raised ``AttributeError`` on ``.get`` and broke deserialization of any serialized thread containing a currentMessage. Fix: - ``Message.from_json`` returns ``data`` unchanged if it's already a Message - ``ThreadImpl.from_json`` / ``ChannelImpl.from_json`` do the same, plus pass the (possibly already-Message) ``currentMessage`` through the now-idempotent ``Message.from_json`` - Regression test exercises the exact object_hook bottom-up path with a nested currentMessage Also moved ``TestStandaloneReviver``'s per-method local imports to the module top level per gemini-code-assist review (PEP 8 E402). https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/channel.py | 8 +++- src/chat_sdk/thread.py | 14 +++++-- src/chat_sdk/types.py | 10 ++++- tests/test_serialization.py | 77 +++++++++++++++++++++++++------------ 4 files changed, 79 insertions(+), 30 deletions(-) diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 02e4187..4d249e9 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -397,7 +397,7 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json( cls, - data: dict[str, Any], + data: dict[str, Any] | ChannelImpl, adapter: Adapter | None = None, chat: _ChatSingleton | None = None, ) -> ChannelImpl: @@ -411,7 +411,13 @@ def from_json( Explicit adapter. Skips singleton lookup. chat: Explicit Chat instance for adapter/state resolution. + + Idempotent: if ``data`` is already a :class:`ChannelImpl`, it is + returned unchanged. This makes it safe to call via + ``json.loads(..., object_hook=reviver)``. """ + if isinstance(data, ChannelImpl): + return data channel = cls( _ChannelImplConfigLazy( id=data["id"], diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index f96f43a..c0a4965 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -739,7 +739,7 @@ def to_json(self) -> dict[str, Any]: @classmethod def from_json( cls, - data: dict[str, Any], + data: dict[str, Any] | ThreadImpl, adapter: Adapter | None = None, chat: _ChatSingleton | None = None, ) -> ThreadImpl: @@ -755,11 +755,17 @@ def from_json( Explicit Chat instance. If provided, adapter and state are resolved from this instance instead of the singleton. Useful in multi-chat or test scenarios. + + Idempotent: if ``data`` is already a :class:`ThreadImpl`, it is + returned unchanged. This makes it safe to call via + ``json.loads(..., object_hook=reviver)``. """ + if isinstance(data, ThreadImpl): + return data current_msg_raw = data.get("currentMessage") or data.get("current_message") - current_msg = None - if current_msg_raw: - current_msg = Message.from_json(current_msg_raw) + # ``object_hook`` revives nested dicts first, so ``currentMessage`` may + # already be a Message instance by the time this runs. + current_msg = Message.from_json(current_msg_raw) if current_msg_raw else None thread = cls( _ThreadImplConfig( diff --git a/src/chat_sdk/types.py b/src/chat_sdk/types.py index f37de5c..e4386a4 100644 --- a/src/chat_sdk/types.py +++ b/src/chat_sdk/types.py @@ -436,14 +436,22 @@ def to_json(self) -> dict[str, Any]: return result @classmethod - def from_json(cls, data: dict[str, Any]) -> Message: + def from_json(cls, data: dict[str, Any] | Message) -> Message: """Reconstruct a Message from serialized JSON data. Converts ISO date strings back to ``datetime`` objects. Accepts both camelCase (canonical output of ``to_json()``) and snake_case keys for backward compatibility. For explicit dual-format handling see :meth:`from_json_compat`. + + Idempotent: if ``data`` is already a :class:`Message`, it is + returned unchanged. This makes it safe to call via + ``json.loads(..., object_hook=reviver)``, where nested values are + revived bottom-up and the outer dict may already contain a revived + instance. """ + if isinstance(data, Message): + return data meta = data.get("metadata", {}) date_sent_raw = meta.get("dateSent") or meta.get("date_sent") diff --git a/tests/test_serialization.py b/tests/test_serialization.py index da8fe18..bcabe87 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -11,11 +11,14 @@ import pytest +from chat_sdk import reviver +from chat_sdk.channel import ChannelImpl +from chat_sdk.chat import Chat from chat_sdk.testing import ( create_mock_adapter, create_test_message, ) -from chat_sdk.thread import ThreadImpl, _ThreadImplConfig +from chat_sdk.thread import ThreadImpl, _ThreadImplConfig, clear_chat_singleton from chat_sdk.types import ( Attachment, Author, @@ -877,10 +880,6 @@ class TestStandaloneReviver: """ def test_should_revive_chatthread_objects(self, mock_adapter, mock_state): - from chat_sdk import reviver - from chat_sdk.chat import Chat - from chat_sdk.thread import clear_chat_singleton - chat = Chat( user_name="test-bot", adapters={"slack": mock_adapter}, @@ -907,10 +906,6 @@ def test_should_revive_chatthread_objects(self, mock_adapter, mock_state): clear_chat_singleton() def test_should_revive_chatmessage_objects(self, mock_adapter, mock_state): - from chat_sdk import reviver - from chat_sdk.chat import Chat - from chat_sdk.thread import clear_chat_singleton - chat = Chat( user_name="test-bot", adapters={"slack": mock_adapter}, @@ -950,10 +945,6 @@ def test_should_revive_chatmessage_objects(self, mock_adapter, mock_state): clear_chat_singleton() def test_should_revive_both_thread_and_message_in_same_payload(self, mock_adapter, mock_state): - from chat_sdk import reviver - from chat_sdk.chat import Chat - from chat_sdk.thread import clear_chat_singleton - chat = Chat( user_name="test-bot", adapters={"slack": mock_adapter}, @@ -1000,8 +991,6 @@ def test_should_revive_both_thread_and_message_in_same_payload(self, mock_adapte clear_chat_singleton() def test_should_leave_nonchat_objects_unchanged(self, mock_adapter, mock_state): - from chat_sdk import reviver - payload = json.dumps( { "name": "test", @@ -1015,10 +1004,6 @@ def test_should_leave_nonchat_objects_unchanged(self, mock_adapter, mock_state): assert parsed["nested"]["_type"] == "other:Type" def test_should_be_usable_directly_as_json_parse_second_argument(self, mock_adapter, mock_state): - from chat_sdk import reviver - from chat_sdk.chat import Chat - from chat_sdk.thread import clear_chat_singleton - chat = Chat( user_name="test-bot", adapters={"slack": mock_adapter}, @@ -1055,8 +1040,6 @@ def test_should_be_usable_directly_as_json_parse_second_argument(self, mock_adap clear_chat_singleton() def test_should_allow_reserialization_of_a_revived_thread_without_singleton(self): - from chat_sdk.thread import clear_chat_singleton - clear_chat_singleton() data = { "_type": "chat:Thread", @@ -1072,9 +1055,6 @@ def test_should_allow_reserialization_of_a_revived_thread_without_singleton(self assert reserialized["id"] == "slack:C123:1234.5678" def test_should_allow_reserialization_of_a_revived_channel_without_singleton(self): - from chat_sdk.channel import ChannelImpl - from chat_sdk.thread import clear_chat_singleton - clear_chat_singleton() data = { "_type": "chat:Channel", @@ -1088,6 +1068,55 @@ def test_should_allow_reserialization_of_a_revived_channel_without_singleton(sel assert reserialized["adapterName"] == "slack" assert reserialized["id"] == "C123" + def test_should_revive_thread_with_nested_current_message_via_object_hook(self, mock_adapter, mock_state): + """``object_hook`` revives children first, so ``currentMessage`` reaches + ``ThreadImpl.from_json`` as a :class:`Message` instance, not a dict. + ``from_json`` must accept that without raising ``AttributeError``.""" + chat = Chat( + user_name="test-bot", + adapters={"slack": mock_adapter}, + state=mock_state, + logger="silent", + ) + chat.register_singleton() + try: + payload = json.dumps( + { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channelId": "C123", + "isDM": False, + "adapterName": "slack", + "currentMessage": { + "_type": "chat:Message", + "id": "msg-current", + "threadId": "slack:C123:1234.5678", + "text": "hi", + "formatted": {"type": "root", "children": []}, + "raw": {}, + "author": { + "userId": "U123", + "userName": "testuser", + "fullName": "Test User", + "isBot": False, + "isMe": False, + }, + "metadata": { + "dateSent": "2024-01-15T10:30:00.000Z", + "edited": False, + }, + "attachments": [], + }, + } + ) + thread = json.loads(payload, object_hook=reviver) + assert isinstance(thread, ThreadImpl) + assert thread._current_message is not None + assert isinstance(thread._current_message, Message) + assert thread._current_message.id == "msg-current" + finally: + clear_chat_singleton() + # ============================================================================ # @workflow/serde integration — ThreadImpl From 2ad1dacc7973fb55618023c9060bc4b5986348b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:21:39 +0000 Subject: [PATCH 05/40] ci: add pyrefly type checking and expand lint coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated `Lint & Type Check` workflow modeled on the chinchill-api pattern: - Ruff check/format now covers src/, tests/, and scripts/ (previously only src/, which let the Gemini PEP 8 finding on tests/test_serialization.py slip past CI). - Pyrefly type check with a baseline file (.pyrefly-baseline.json). Baseline captures the 213 existing errors in src/ so we can enable pyrefly immediately without a large type-cleanup detour — new errors fail CI, pre-existing ones are allowed. - Audit step moved from the test matrix into the lint job so the pytest workflow only runs tests. - Per-step `continue-on-error: true` with aggregate failure at the end, so one failure doesn't hide the others — developers see all issues at once. Optional adapter deps (slack_sdk, nacl, google, redis, asyncpg, httpx, jwt) are listed in `replace-imports-with-any` so lazy imports don't flag missing-import when the corresponding extra isn't installed. Refresh baseline after legitimate fixes with: uv run pyrefly check --baseline=.pyrefly-baseline.json --update-baseline https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .github/workflows/lint.yml | 115 ++ .github/workflows/test.yml | 6 - .pyrefly-baseline.json | 2560 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 42 + 4 files changed, 2717 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/lint.yml create mode 100644 .pyrefly-baseline.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..12c6856 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,115 @@ +name: Lint & Type Check + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'tests/**' + - 'scripts/**' + - 'pyproject.toml' + - '.pyrefly-baseline.json' + - '.github/workflows/lint.yml' + pull_request: + branches: [main] + types: [opened, reopened, ready_for_review, synchronize] + paths: + - 'src/**' + - 'tests/**' + - 'scripts/**' + - 'pyproject.toml' + - '.pyrefly-baseline.json' + - '.github/workflows/lint.yml' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + UV_CACHE_DIR: /tmp/.uv-cache + +jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + if: "!github.event.pull_request.draft" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.2.2 + with: + fetch-depth: 1 + + - uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 + with: + enable-cache: true + + - name: Restore uv cache + uses: actions/cache@v4 + with: + path: /tmp/.uv-cache + key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + restore-keys: | + uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} + uv-${{ runner.os }} + + - name: Install dependencies + run: uv sync --group dev + + - name: Ruff check + id: ruff_check + continue-on-error: true + run: uv run ruff check src/ tests/ scripts/ + + - name: Ruff format check + id: ruff_format + continue-on-error: true + run: uv run ruff format --check src/ tests/ scripts/ + + - name: Test quality audit + id: audit + continue-on-error: true + run: uv run python scripts/audit_test_quality.py + + - name: Pyrefly type check (against baseline) + id: pyrefly + continue-on-error: true + run: uv run pyrefly check --baseline=.pyrefly-baseline.json + + - name: Minimize uv cache + run: uv cache prune --ci + + - name: Report results + if: always() + run: | + echo "## Lint & Type Check Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Step | Outcome |" >> $GITHUB_STEP_SUMMARY + echo "|---|---|" >> $GITHUB_STEP_SUMMARY + echo "| Ruff check | ${{ steps.ruff_check.outcome }} |" >> $GITHUB_STEP_SUMMARY + echo "| Ruff format | ${{ steps.ruff_format.outcome }} |" >> $GITHUB_STEP_SUMMARY + echo "| Test audit | ${{ steps.audit.outcome }} |" >> $GITHUB_STEP_SUMMARY + echo "| Pyrefly | ${{ steps.pyrefly.outcome }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.pyrefly.outcome }}" = "success" ]; then + echo "No new type issues above the baseline." >> $GITHUB_STEP_SUMMARY + else + echo "New type issues above the baseline. Either fix them or refresh the baseline with \`uv run pyrefly check --baseline=.pyrefly-baseline.json --update-baseline\` if intentional." >> $GITHUB_STEP_SUMMARY + fi + + - name: Fail if any step failed + if: always() + env: + RUFF_CHECK: ${{ steps.ruff_check.outcome }} + RUFF_FORMAT: ${{ steps.ruff_format.outcome }} + AUDIT: ${{ steps.audit.outcome }} + PYREFLY: ${{ steps.pyrefly.outcome }} + run: | + failures=0 + for var in RUFF_CHECK RUFF_FORMAT AUDIT PYREFLY; do + outcome="${!var}" + if [ "$outcome" != "success" ]; then + echo "$var failed (outcome: $outcome)" + failures=1 + fi + done + [ "$failures" -eq 0 ] || exit 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2168258..64acdc5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,11 +19,5 @@ jobs: run: uv python install ${{ matrix.python-version }} - name: Install dependencies run: uv sync --group dev - - name: Lint - run: uv run ruff check src/ - - name: Format check - run: uv run ruff format --check src/ - - name: Test quality audit - run: uv run python scripts/audit_test_quality.py - name: Run tests run: uv run pytest tests/ -q --tb=short --cov=chat_sdk --cov-fail-under=70 diff --git a/.pyrefly-baseline.json b/.pyrefly-baseline.json new file mode 100644 index 0000000..de42515 --- /dev/null +++ b/.pyrefly-baseline.json @@ -0,0 +1,2560 @@ +{ + "errors": [ + { + "line": 276, + "column": 20, + "stop_line": 276, + "stop_column": 32, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `nacl.signing`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `nacl.signing`", + "severity": "error" + }, + { + "line": 366, + "column": 25, + "stop_line": 366, + "stop_column": 29, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@DiscordAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@DiscordAdapter.lock_scope` has type `(self: Self@DiscordAdapter) -> str | None`, which is not assignable to `(self: Self@DiscordAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@DiscordAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 416, + "column": 74, + "stop_line": 416, + "stop_column": 83, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Literal['options']` is not assignable to parameter `key` with type `Literal['name']` in function `dict.get`", + "concise_description": "Argument `Literal['options']` is not assignable to parameter `key` with type `Literal['name']` in function `dict.get`", + "severity": "error" + }, + { + "line": 449, + "column": 21, + "stop_line": 449, + "stop_column": 25, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@DiscordAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.SlashCommandEvent.__init__`\n Property getter for `Self@DiscordAdapter.lock_scope` has type `(self: Self@DiscordAdapter) -> str | None`, which is not assignable to `(self: Self@DiscordAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@DiscordAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.SlashCommandEvent.__init__`", + "severity": "error" + }, + { + "line": 450, + "column": 21, + "stop_line": 450, + "stop_column": 25, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `channel` with type `Channel` in function `chat_sdk.types.SlashCommandEvent.__init__`\n Protocol `Channel` requires attribute `name`", + "concise_description": "Argument `None` is not assignable to parameter `channel` with type `Channel` in function `chat_sdk.types.SlashCommandEvent.__init__`", + "severity": "error" + }, + { + "line": 599, + "column": 26, + "stop_line": 599, + "stop_column": 74, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `str` is not assignable to parameter `type` with type `Literal['audio', 'file', 'image', 'video']` in function `chat_sdk.types.Attachment.__init__`", + "concise_description": "Argument `str` is not assignable to parameter `type` with type `Literal['audio', 'file', 'image', 'video']` in function `chat_sdk.types.Attachment.__init__`", + "severity": "error" + }, + { + "line": 612, + "column": 19, + "stop_line": 612, + "stop_column": 53, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "missing-attribute", + "description": "Object of class `ChatInstance` has no attribute `handle_incoming_message`", + "concise_description": "Object of class `ChatInstance` has no attribute `handle_incoming_message`", + "severity": "error" + }, + { + "line": 706, + "column": 25, + "stop_line": 706, + "stop_column": 29, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@DiscordAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@DiscordAdapter.lock_scope` has type `(self: Self@DiscordAdapter) -> str | None`, which is not assignable to `(self: Self@DiscordAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@DiscordAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 707, + "column": 24, + "stop_line": 707, + "stop_column": 28, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1162, + "column": 48, + "stop_line": 1162, + "stop_column": 68, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-assignment", + "description": "`dict[str, str | Unknown]` is not assignable to `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement`", + "concise_description": "`dict[str, str | Unknown]` is not assignable to `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement`", + "severity": "error" + }, + { + "line": 1249, + "column": 26, + "stop_line": 1249, + "stop_column": 76, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `str` is not assignable to parameter `type` with type `Literal['audio', 'file', 'image', 'video']` in function `chat_sdk.types.Attachment.__init__`", + "concise_description": "Argument `str` is not assignable to parameter `type` with type `Literal['audio', 'file', 'image', 'video']` in function `chat_sdk.types.Attachment.__init__`", + "severity": "error" + }, + { + "line": 1411, + "column": 24, + "stop_line": 1411, + "stop_column": 44, + "path": "src/chat_sdk/adapters/discord/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 118, + "column": 46, + "stop_line": 118, + "stop_column": 51, + "path": "src/chat_sdk/adapters/discord/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_convert_emoji`", + "concise_description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_convert_emoji`", + "severity": "error" + }, + { + "line": 122, + "column": 54, + "stop_line": 122, + "stop_column": 61, + "path": "src/chat_sdk/adapters/discord/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "concise_description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "severity": "error" + }, + { + "line": 122, + "column": 63, + "stop_line": 122, + "stop_column": 67, + "path": "src/chat_sdk/adapters/discord/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "concise_description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "severity": "error" + }, + { + "line": 275, + "column": 47, + "stop_line": 275, + "stop_column": 54, + "path": "src/chat_sdk/adapters/discord/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.cards.table_element_to_ascii`", + "concise_description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.cards.table_element_to_ascii`", + "severity": "error" + }, + { + "line": 275, + "column": 56, + "stop_line": 275, + "stop_column": 60, + "path": "src/chat_sdk/adapters/discord/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.cards.table_element_to_ascii`", + "concise_description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.cards.table_element_to_ascii`", + "severity": "error" + }, + { + "line": 121, + "column": 39, + "stop_line": 121, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAppConfig` does not have key `token`", + "concise_description": "TypedDict `GitHubAdapterAppConfig` does not have key `token`", + "severity": "error" + }, + { + "line": 121, + "column": 39, + "stop_line": 121, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAutoConfig` does not have key `token`", + "concise_description": "TypedDict `GitHubAdapterAutoConfig` does not have key `token`", + "severity": "error" + }, + { + "line": 121, + "column": 39, + "stop_line": 121, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterMultiTenantAppConfig` does not have key `token`", + "concise_description": "TypedDict `GitHubAdapterMultiTenantAppConfig` does not have key `token`", + "severity": "error" + }, + { + "line": 125, + "column": 38, + "stop_line": 125, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAutoConfig` does not have key `app_id`", + "concise_description": "TypedDict `GitHubAdapterAutoConfig` does not have key `app_id`", + "severity": "error" + }, + { + "line": 125, + "column": 38, + "stop_line": 125, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterPATConfig` does not have key `app_id`", + "concise_description": "TypedDict `GitHubAdapterPATConfig` does not have key `app_id`", + "severity": "error" + }, + { + "line": 126, + "column": 43, + "stop_line": 126, + "stop_column": 56, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAutoConfig` does not have key `private_key`", + "concise_description": "TypedDict `GitHubAdapterAutoConfig` does not have key `private_key`", + "severity": "error" + }, + { + "line": 126, + "column": 43, + "stop_line": 126, + "stop_column": 56, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterPATConfig` does not have key `private_key`", + "concise_description": "TypedDict `GitHubAdapterPATConfig` does not have key `private_key`", + "severity": "error" + }, + { + "line": 128, + "column": 48, + "stop_line": 128, + "stop_column": 65, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAutoConfig` does not have key `installation_id`", + "concise_description": "TypedDict `GitHubAdapterAutoConfig` does not have key `installation_id`", + "severity": "error" + }, + { + "line": 128, + "column": 48, + "stop_line": 128, + "stop_column": 65, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterMultiTenantAppConfig` does not have key `installation_id`", + "concise_description": "TypedDict `GitHubAdapterMultiTenantAppConfig` does not have key `installation_id`", + "severity": "error" + }, + { + "line": 128, + "column": 48, + "stop_line": 128, + "stop_column": 65, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterPATConfig` does not have key `installation_id`", + "concise_description": "TypedDict `GitHubAdapterPATConfig` does not have key `installation_id`", + "severity": "error" + }, + { + "line": 131, + "column": 38, + "stop_line": 131, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAutoConfig` does not have key `app_id`", + "concise_description": "TypedDict `GitHubAdapterAutoConfig` does not have key `app_id`", + "severity": "error" + }, + { + "line": 131, + "column": 38, + "stop_line": 131, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterPATConfig` does not have key `app_id`", + "concise_description": "TypedDict `GitHubAdapterPATConfig` does not have key `app_id`", + "severity": "error" + }, + { + "line": 132, + "column": 43, + "stop_line": 132, + "stop_column": 56, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterAutoConfig` does not have key `private_key`", + "concise_description": "TypedDict `GitHubAdapterAutoConfig` does not have key `private_key`", + "severity": "error" + }, + { + "line": 132, + "column": 43, + "stop_line": 132, + "stop_column": 56, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `GitHubAdapterPATConfig` does not have key `private_key`", + "concise_description": "TypedDict `GitHubAdapterPATConfig` does not have key `private_key`", + "severity": "error" + }, + { + "line": 317, + "column": 54, + "stop_line": 317, + "stop_column": 64, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_issue_comment`", + "concise_description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_issue_comment`", + "severity": "error" + }, + { + "line": 323, + "column": 36, + "stop_line": 323, + "stop_column": 40, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@GitHubAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@GitHubAdapter.lock_scope` has type `(self: Self@GitHubAdapter) -> str | None`, which is not assignable to `(self: Self@GitHubAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@GitHubAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 352, + "column": 55, + "stop_line": 352, + "stop_column": 65, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_review_comment`", + "concise_description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_review_comment`", + "severity": "error" + }, + { + "line": 358, + "column": 36, + "stop_line": 358, + "stop_column": 40, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@GitHubAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@GitHubAdapter.lock_scope` has type `(self: Self@GitHubAdapter) -> str | None`, which is not assignable to `(self: Self@GitHubAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@GitHubAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 526, + "column": 51, + "stop_line": 526, + "stop_column": 69, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, str]` is not assignable to parameter `message` with type `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement` in function `GitHubAdapter.post_message`", + "concise_description": "Argument `dict[str, str]` is not assignable to parameter `message` with type `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement` in function `GitHubAdapter.post_message`", + "severity": "error" + }, + { + "line": 894, + "column": 26, + "stop_line": 894, + "stop_column": 37, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `type` with type `Literal['issue', 'pr'] | None` in function `chat_sdk.adapters.github.types.GitHubThreadId.__init__`", + "concise_description": "Argument `object | str` is not assignable to parameter `type` with type `Literal['issue', 'pr'] | None` in function `chat_sdk.adapters.github.types.GitHubThreadId.__init__`", + "severity": "error" + }, + { + "line": 898, + "column": 33, + "stop_line": 898, + "stop_column": 50, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_issue_comment`", + "concise_description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_issue_comment`", + "severity": "error" + }, + { + "line": 898, + "column": 81, + "stop_line": 898, + "stop_column": 92, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `thread_type` with type `Literal['issue', 'pr']` in function `GitHubAdapter._parse_issue_comment`", + "concise_description": "Argument `object | str` is not assignable to parameter `thread_type` with type `Literal['issue', 'pr']` in function `GitHubAdapter._parse_issue_comment`", + "severity": "error" + }, + { + "line": 907, + "column": 39, + "stop_line": 907, + "stop_column": 54, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `int | object` is not assignable to parameter `review_comment_id` with type `int | None` in function `chat_sdk.adapters.github.types.GitHubThreadId.__init__`", + "concise_description": "Argument `int | object` is not assignable to parameter `review_comment_id` with type `int | None` in function `chat_sdk.adapters.github.types.GitHubThreadId.__init__`", + "severity": "error" + }, + { + "line": 910, + "column": 47, + "stop_line": 910, + "stop_column": 61, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `GitHubIssueComment | GitHubReviewComment` is not assignable to parameter `comment` with type `GitHubReviewComment` in function `GitHubAdapter._parse_review_comment`\n Field `commit_id` is present in `GitHubReviewComment` and absent in `GitHubIssueComment`", + "concise_description": "Argument `GitHubIssueComment | GitHubReviewComment` is not assignable to parameter `comment` with type `GitHubReviewComment` in function `GitHubAdapter._parse_review_comment`", + "severity": "error" + }, + { + "line": 910, + "column": 63, + "stop_line": 910, + "stop_column": 80, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_review_comment`", + "concise_description": "Argument `GitHubRepository` is not assignable to parameter `repository` with type `dict[str, Any]` in function `GitHubAdapter._parse_review_comment`", + "severity": "error" + }, + { + "line": 1068, + "column": 20, + "stop_line": 1068, + "stop_column": 40, + "path": "src/chat_sdk/adapters/github/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 99, + "column": 29, + "stop_line": 99, + "stop_column": 34, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `text` with type `dict[str, Any]` in function `_render_text`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `text` with type `dict[str, Any]` in function `_render_text`", + "severity": "error" + }, + { + "line": 102, + "column": 31, + "stop_line": 102, + "stop_column": 36, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `fields` with type `dict[str, Any]` in function `_render_fields`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `fields` with type `dict[str, Any]` in function `_render_fields`", + "severity": "error" + }, + { + "line": 105, + "column": 32, + "stop_line": 105, + "stop_column": 37, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `actions` with type `dict[str, Any]` in function `_render_actions`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `actions` with type `dict[str, Any]` in function `_render_actions`", + "severity": "error" + }, + { + "line": 110, + "column": 30, + "stop_line": 110, + "stop_column": 55, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "not-iterable", + "description": "Type `object` is not iterable", + "concise_description": "Type `object` is not iterable", + "severity": "error" + }, + { + "line": 118, + "column": 43, + "stop_line": 118, + "stop_column": 46, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "concise_description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "severity": "error" + }, + { + "line": 124, + "column": 38, + "stop_line": 124, + "stop_column": 43, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "concise_description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "severity": "error" + }, + { + "line": 130, + "column": 30, + "stop_line": 130, + "stop_column": 35, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `table` with type `dict[str, Any]` in function `_render_table`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `table` with type `dict[str, Any]` in function `_render_table`", + "severity": "error" + }, + { + "line": 236, + "column": 40, + "stop_line": 236, + "stop_column": 45, + "path": "src/chat_sdk/adapters/github/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `table` with type `dict[str, Any]` in function `_render_table`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `table` with type `dict[str, Any]` in function `_render_table`", + "severity": "error" + }, + { + "line": 37, + "column": 9, + "stop_line": 37, + "stop_column": 15, + "path": "src/chat_sdk/adapters/github/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `GitHubFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `markdown`, expected `platform_text`", + "concise_description": "Class member `GitHubFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 326, + "column": 20, + "stop_line": 326, + "stop_column": 31, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `google.auth`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `google.auth`", + "severity": "error" + }, + { + "line": 327, + "column": 20, + "stop_line": 327, + "stop_column": 50, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `google.auth.transport.requests`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `google.auth.transport.requests`", + "severity": "error" + }, + { + "line": 764, + "column": 20, + "stop_line": 764, + "stop_column": 40, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 954, + "column": 13, + "stop_line": 954, + "stop_column": 17, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@GoogleChatAdapter.lock_scope` has type `(self: Self@GoogleChatAdapter) -> str | None`, which is not assignable to `(self: Self@GoogleChatAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 1024, + "column": 25, + "stop_line": 1024, + "stop_column": 29, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@GoogleChatAdapter.lock_scope` has type `(self: Self@GoogleChatAdapter) -> str | None`, which is not assignable to `(self: Self@GoogleChatAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1025, + "column": 24, + "stop_line": 1025, + "stop_column": 28, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1192, + "column": 21, + "stop_line": 1192, + "stop_column": 25, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@GoogleChatAdapter.lock_scope` has type `(self: Self@GoogleChatAdapter) -> str | None`, which is not assignable to `(self: Self@GoogleChatAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 1252, + "column": 13, + "stop_line": 1252, + "stop_column": 17, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@GoogleChatAdapter.lock_scope` has type `(self: Self@GoogleChatAdapter) -> str | None`, which is not assignable to `(self: Self@GoogleChatAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@GoogleChatAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 1320, + "column": 10, + "stop_line": 1320, + "stop_column": 20, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 1430, + "column": 10, + "stop_line": 1430, + "stop_column": 26, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `EphemeralMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `EphemeralMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 1507, + "column": 10, + "stop_line": 1507, + "stop_column": 20, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 1597, + "column": 51, + "stop_line": 1597, + "stop_column": 76, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, str]` is not assignable to parameter `message` with type `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement` in function `GoogleChatAdapter.post_message`", + "concise_description": "Argument `dict[str, str]` is not assignable to parameter `message` with type `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement` in function `GoogleChatAdapter.post_message`", + "severity": "error" + }, + { + "line": 1690, + "column": 46, + "stop_line": 1690, + "stop_column": 49, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `str`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `str`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 1788, + "column": 10, + "stop_line": 1788, + "stop_column": 21, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 1999, + "column": 53, + "stop_line": 1999, + "stop_column": 63, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ThreadInfo`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ThreadInfo`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2039, + "column": 10, + "stop_line": 2039, + "stop_column": 21, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2232, + "column": 10, + "stop_line": 2232, + "stop_column": 27, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ListThreadsResult`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ListThreadsResult`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2336, + "column": 60, + "stop_line": 2336, + "stop_column": 71, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ChannelInfo`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ChannelInfo`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2384, + "column": 10, + "stop_line": 2384, + "stop_column": 20, + "path": "src/chat_sdk/adapters/google_chat/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 76, + "column": 59, + "stop_line": 76, + "stop_column": 64, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement | Unknown` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_section_to_widgets`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement | Unknown` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_section_to_widgets`", + "severity": "error" + }, + { + "line": 115, + "column": 41, + "stop_line": 115, + "stop_column": 46, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_text_to_widget`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_text_to_widget`", + "severity": "error" + }, + { + "line": 117, + "column": 42, + "stop_line": 117, + "stop_column": 47, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_image_to_widget`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_image_to_widget`", + "severity": "error" + }, + { + "line": 121, + "column": 44, + "stop_line": 121, + "stop_column": 49, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_actions_to_widget`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_actions_to_widget`", + "severity": "error" + }, + { + "line": 123, + "column": 44, + "stop_line": 123, + "stop_column": 49, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_section_to_widgets`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_section_to_widgets`", + "severity": "error" + }, + { + "line": 125, + "column": 43, + "stop_line": 125, + "stop_column": 48, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_fields_to_widgets`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_fields_to_widgets`", + "severity": "error" + }, + { + "line": 132, + "column": 62, + "stop_line": 132, + "stop_column": 67, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter with type `str`", + "concise_description": "Argument `object | str` is not assignable to parameter with type `str`", + "severity": "error" + }, + { + "line": 137, + "column": 42, + "stop_line": 137, + "stop_column": 47, + "path": "src/chat_sdk/adapters/google_chat/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_table_to_widget`", + "concise_description": "Argument `ActionsElement | DividerElement | FieldsElement | ImageElement | LinkElement | SectionElement | TableElement | TextElement` is not assignable to parameter `element` with type `dict[str, Any]` in function `_convert_table_to_widget`", + "severity": "error" + }, + { + "line": 37, + "column": 9, + "stop_line": 37, + "stop_column": 15, + "path": "src/chat_sdk/adapters/google_chat/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `GoogleChatFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `gchat_text`, expected `platform_text`", + "concise_description": "Class member `GoogleChatFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 54, + "column": 9, + "stop_line": 54, + "stop_column": 27, + "path": "src/chat_sdk/adapters/google_chat/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `GoogleChatFormatConverter.extract_plain_text` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `text`, expected `platform_text`", + "concise_description": "Class member `GoogleChatFormatConverter.extract_plain_text` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 346, + "column": 20, + "stop_line": 346, + "stop_column": 39, + "path": "src/chat_sdk/adapters/google_chat/workspace_events.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 406, + "column": 16, + "stop_line": 406, + "stop_column": 27, + "path": "src/chat_sdk/adapters/google_chat/workspace_events.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `google.auth`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `google.auth`", + "severity": "error" + }, + { + "line": 407, + "column": 16, + "stop_line": 407, + "stop_column": 46, + "path": "src/chat_sdk/adapters/google_chat/workspace_events.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `google.auth.transport.requests`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `google.auth.transport.requests`", + "severity": "error" + }, + { + "line": 305, + "column": 46, + "stop_line": 305, + "stop_column": 53, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `payload` with type `CommentWebhookPayload` in function `LinearAdapter._handle_comment_created`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `payload` with type `CommentWebhookPayload` in function `LinearAdapter._handle_comment_created`", + "severity": "error" + }, + { + "line": 307, + "column": 35, + "stop_line": 307, + "stop_column": 42, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `payload` with type `ReactionWebhookPayload` in function `LinearAdapter._handle_reaction`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `payload` with type `ReactionWebhookPayload` in function `LinearAdapter._handle_reaction`", + "severity": "error" + }, + { + "line": 354, + "column": 26, + "stop_line": 354, + "stop_column": 34, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str | Unknown` is not assignable to parameter `issue_id` with type `str` in function `chat_sdk.adapters.linear.types.LinearThreadId.__init__`", + "concise_description": "Argument `object | str | Unknown` is not assignable to parameter `issue_id` with type `str` in function `chat_sdk.adapters.linear.types.LinearThreadId.__init__`", + "severity": "error" + }, + { + "line": 355, + "column": 28, + "stop_line": 355, + "stop_column": 43, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str | Unknown | None` is not assignable to parameter `comment_id` with type `str | None` in function `chat_sdk.adapters.linear.types.LinearThreadId.__init__`", + "concise_description": "Argument `object | str | Unknown | None` is not assignable to parameter `comment_id` with type `str | None` in function `chat_sdk.adapters.linear.types.LinearThreadId.__init__`", + "severity": "error" + }, + { + "line": 359, + "column": 39, + "stop_line": 359, + "stop_column": 43, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[Unknown, Unknown] | LinearCommentData` is not assignable to parameter `comment` with type `LinearCommentData` in function `LinearAdapter._build_message`", + "concise_description": "Argument `dict[Unknown, Unknown] | LinearCommentData` is not assignable to parameter `comment` with type `LinearCommentData` in function `LinearAdapter._build_message`", + "severity": "error" + }, + { + "line": 359, + "column": 45, + "stop_line": 359, + "stop_column": 50, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[Unknown, Unknown] | LinearWebhookActor` is not assignable to parameter `actor` with type `LinearWebhookActor` in function `LinearAdapter._build_message`", + "concise_description": "Argument `dict[Unknown, Unknown] | LinearWebhookActor` is not assignable to parameter `actor` with type `LinearWebhookActor` in function `LinearAdapter._build_message`", + "severity": "error" + }, + { + "line": 367, + "column": 36, + "stop_line": 367, + "stop_column": 40, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@LinearAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@LinearAdapter.lock_scope` has type `(self: Self@LinearAdapter) -> str | None`, which is not assignable to `(self: Self@LinearAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@LinearAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 399, + "column": 21, + "stop_line": 399, + "stop_column": 28, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `user_id` with type `str` in function `chat_sdk.types.Author.__init__`", + "concise_description": "Argument `object | str` is not assignable to parameter `user_id` with type `str` in function `chat_sdk.types.Author.__init__`", + "severity": "error" + }, + { + "line": 419, + "column": 38, + "stop_line": 419, + "stop_column": 48, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "concise_description": "Argument `object | str` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "severity": "error" + }, + { + "line": 421, + "column": 38, + "stop_line": 421, + "stop_column": 48, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "concise_description": "Argument `object | str` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "severity": "error" + }, + { + "line": 742, + "column": 28, + "stop_line": 742, + "stop_column": 43, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "`Any | None` is not assignable to TypedDict key `url` with type `str`", + "concise_description": "`Any | None` is not assignable to TypedDict key `url` with type `str`", + "severity": "error" + }, + { + "line": 849, + "column": 25, + "stop_line": 849, + "stop_column": 32, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str | Unknown` is not assignable to parameter `user_id` with type `str` in function `chat_sdk.types.Author.__init__`", + "concise_description": "Argument `object | str | Unknown` is not assignable to parameter `user_id` with type `str` in function `chat_sdk.types.Author.__init__`", + "severity": "error" + }, + { + "line": 856, + "column": 39, + "stop_line": 856, + "stop_column": 49, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str | Unknown` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "concise_description": "Argument `object | str | Unknown` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "severity": "error" + }, + { + "line": 858, + "column": 39, + "stop_line": 858, + "stop_column": 49, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str | Unknown` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "concise_description": "Argument `object | str | Unknown` is not assignable to parameter `s` with type `str` in function `chat_sdk.types._parse_iso`", + "severity": "error" + }, + { + "line": 894, + "column": 48, + "stop_line": 894, + "stop_column": 68, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "bad-assignment", + "description": "`dict[str, str | Unknown]` is not assignable to `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement`", + "concise_description": "`dict[str, str | Unknown]` is not assignable to `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement`", + "severity": "error" + }, + { + "line": 973, + "column": 24, + "stop_line": 973, + "stop_column": 44, + "path": "src/chat_sdk/adapters/linear/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 94, + "column": 43, + "stop_line": 94, + "stop_column": 46, + "path": "src/chat_sdk/adapters/linear/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "concise_description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "severity": "error" + }, + { + "line": 99, + "column": 38, + "stop_line": 99, + "stop_column": 43, + "path": "src/chat_sdk/adapters/linear/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "concise_description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_escape_markdown`", + "severity": "error" + }, + { + "line": 190, + "column": 43, + "stop_line": 190, + "stop_column": 50, + "path": "src/chat_sdk/adapters/linear/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "concise_description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "severity": "error" + }, + { + "line": 190, + "column": 52, + "stop_line": 190, + "stop_column": 56, + "path": "src/chat_sdk/adapters/linear/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "concise_description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.shared.card_utils.render_gfm_table`", + "severity": "error" + }, + { + "line": 290, + "column": 9, + "stop_line": 290, + "stop_column": 62, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `slack_sdk.web.async_client`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `slack_sdk.web.async_client`", + "severity": "error" + }, + { + "line": 401, + "column": 24, + "stop_line": 401, + "stop_column": 43, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "unsupported-operation", + "description": "`None` is not subscriptable", + "concise_description": "`None` is not subscriptable", + "severity": "error" + }, + { + "line": 402, + "column": 26, + "stop_line": 402, + "stop_column": 47, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "unsupported-operation", + "description": "`None` is not subscriptable", + "concise_description": "`None` is not subscriptable", + "severity": "error" + }, + { + "line": 403, + "column": 25, + "stop_line": 403, + "stop_column": 45, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "unsupported-operation", + "description": "`None` is not subscriptable", + "concise_description": "`None` is not subscriptable", + "severity": "error" + }, + { + "line": 646, + "column": 25, + "stop_line": 646, + "stop_column": 45, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 667, + "column": 12, + "stop_line": 667, + "stop_column": 63, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "not-iterable", + "description": "`in` is not supported between `Literal['application/x-www-form-urlencoded']` and `None`", + "concise_description": "`in` is not supported between `Literal['application/x-www-form-urlencoded']` and `None`", + "severity": "error" + }, + { + "line": 865, + "column": 21, + "stop_line": 865, + "stop_column": 25, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.SlashCommandEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.SlashCommandEvent.__init__`", + "severity": "error" + }, + { + "line": 866, + "column": 21, + "stop_line": 866, + "stop_column": 25, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `channel` with type `Channel` in function `chat_sdk.types.SlashCommandEvent.__init__`\n Protocol `Channel` requires attribute `name`", + "concise_description": "Argument `None` is not assignable to parameter `channel` with type `Channel` in function `chat_sdk.types.SlashCommandEvent.__init__`", + "severity": "error" + }, + { + "line": 926, + "column": 25, + "stop_line": 926, + "stop_column": 29, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 980, + "column": 21, + "stop_line": 980, + "stop_column": 25, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ModalSubmitEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ModalSubmitEvent.__init__`", + "severity": "error" + }, + { + "line": 1016, + "column": 21, + "stop_line": 1016, + "stop_column": 25, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ModalCloseEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ModalCloseEvent.__init__`", + "severity": "error" + }, + { + "line": 1036, + "column": 44, + "stop_line": 1036, + "stop_column": 49, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[Any, Any]` is not assignable to parameter `modal` with type `ModalElement` in function `chat_sdk.adapters.slack.modals.modal_to_slack_view`", + "concise_description": "Argument `dict[Any, Any]` is not assignable to parameter `modal` with type `ModalElement` in function `chat_sdk.adapters.slack.modals.modal_to_slack_view`", + "severity": "error" + }, + { + "line": 1074, + "column": 36, + "stop_line": 1074, + "stop_column": 40, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 1144, + "column": 24, + "stop_line": 1144, + "stop_column": 28, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1146, + "column": 25, + "stop_line": 1146, + "stop_column": 29, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1186, + "column": 25, + "stop_line": 1186, + "stop_column": 29, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.AssistantThreadStartedEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.AssistantThreadStartedEvent.__init__`", + "severity": "error" + }, + { + "line": 1221, + "column": 25, + "stop_line": 1221, + "stop_column": 29, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.AssistantContextChangedEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.AssistantContextChangedEvent.__init__`", + "severity": "error" + }, + { + "line": 1248, + "column": 25, + "stop_line": 1248, + "stop_column": 29, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.AppHomeOpenedEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.AppHomeOpenedEvent.__init__`", + "severity": "error" + }, + { + "line": 1262, + "column": 25, + "stop_line": 1262, + "stop_column": 29, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.MemberJoinedChannelEvent.__init__`\n Property getter for `Self@SlackAdapter.lock_scope` has type `(self: Self@SlackAdapter) -> str`, which is not assignable to `(self: Self@SlackAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@SlackAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.MemberJoinedChannelEvent.__init__`", + "severity": "error" + }, + { + "line": 1749, + "column": 86, + "stop_line": 1749, + "stop_column": 96, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 1842, + "column": 10, + "stop_line": 1842, + "stop_column": 20, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `RawMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2134, + "column": 10, + "stop_line": 2134, + "stop_column": 26, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `EphemeralMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `EphemeralMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2203, + "column": 10, + "stop_line": 2203, + "stop_column": 26, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ScheduledMessage`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ScheduledMessage`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2268, + "column": 46, + "stop_line": 2268, + "stop_column": 49, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `str`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `str`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2287, + "column": 106, + "stop_line": 2287, + "stop_column": 120, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `dict[str, str]`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `dict[str, str]`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2295, + "column": 36, + "stop_line": 2295, + "stop_column": 41, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `modal` with type `ModalElement` in function `chat_sdk.adapters.slack.modals.modal_to_slack_view`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `modal` with type `ModalElement` in function `chat_sdk.adapters.slack.modals.modal_to_slack_view`", + "severity": "error" + }, + { + "line": 2310, + "column": 74, + "stop_line": 2310, + "stop_column": 88, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `dict[str, str]`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `dict[str, str]`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2312, + "column": 36, + "stop_line": 2312, + "stop_column": 41, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `modal` with type `ModalElement` in function `chat_sdk.adapters.slack.modals.modal_to_slack_view`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `modal` with type `ModalElement` in function `chat_sdk.adapters.slack.modals.modal_to_slack_view`", + "severity": "error" + }, + { + "line": 2370, + "column": 92, + "stop_line": 2370, + "stop_column": 103, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2455, + "column": 53, + "stop_line": 2455, + "stop_column": 63, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ThreadInfo`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ThreadInfo`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2496, + "column": 101, + "stop_line": 2496, + "stop_column": 112, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `FetchResult`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2573, + "column": 97, + "stop_line": 2573, + "stop_column": 114, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ListThreadsResult`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ListThreadsResult`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 2621, + "column": 60, + "stop_line": 2621, + "stop_column": 71, + "path": "src/chat_sdk/adapters/slack/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Function declared to return `ChannelInfo`, but one or more paths are missing an explicit `return`", + "concise_description": "Function declared to return `ChannelInfo`, but one or more paths are missing an explicit `return`", + "severity": "error" + }, + { + "line": 40, + "column": 9, + "stop_line": 40, + "stop_column": 15, + "path": "src/chat_sdk/adapters/slack/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `SlackFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `mrkdwn`, expected `platform_text`", + "concise_description": "Class member `SlackFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 97, + "column": 9, + "stop_line": 97, + "stop_column": 27, + "path": "src/chat_sdk/adapters/slack/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `SlackFormatConverter.extract_plain_text` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `mrkdwn`, expected `platform_text`", + "concise_description": "Class member `SlackFormatConverter.extract_plain_text` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 351, + "column": 36, + "stop_line": 351, + "stop_column": 40, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@TeamsAdapter.lock_scope` has type `(self: Self@TeamsAdapter) -> str | None`, which is not assignable to `(self: Self@TeamsAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 417, + "column": 25, + "stop_line": 417, + "stop_column": 29, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@TeamsAdapter.lock_scope` has type `(self: Self@TeamsAdapter) -> str | None`, which is not assignable to `(self: Self@TeamsAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 460, + "column": 25, + "stop_line": 460, + "stop_column": 29, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@TeamsAdapter.lock_scope` has type `(self: Self@TeamsAdapter) -> str | None`, which is not assignable to `(self: Self@TeamsAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 506, + "column": 28, + "stop_line": 506, + "stop_column": 32, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 507, + "column": 29, + "stop_line": 507, + "stop_column": 33, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@TeamsAdapter.lock_scope` has type `(self: Self@TeamsAdapter) -> str | None`, which is not assignable to `(self: Self@TeamsAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 523, + "column": 28, + "stop_line": 523, + "stop_column": 32, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 524, + "column": 29, + "stop_line": 524, + "stop_column": 33, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@TeamsAdapter.lock_scope` has type `(self: Self@TeamsAdapter) -> str | None`, which is not assignable to `(self: Self@TeamsAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TeamsAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 582, + "column": 18, + "stop_line": 582, + "stop_column": 26, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `str` is not assignable to parameter `type` with type `Literal['audio', 'file', 'image', 'video']` in function `chat_sdk.types.Attachment.__init__`", + "concise_description": "Argument `str` is not assignable to parameter `type` with type `Literal['audio', 'file', 'image', 'video']` in function `chat_sdk.types.Attachment.__init__`", + "severity": "error" + }, + { + "line": 1781, + "column": 24, + "stop_line": 1781, + "stop_column": 44, + "path": "src/chat_sdk/adapters/teams/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 138, + "column": 48, + "stop_line": 138, + "stop_column": 53, + "path": "src/chat_sdk/adapters/teams/cards.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_convert_emoji`", + "concise_description": "Argument `object | str` is not assignable to parameter `text` with type `str` in function `_convert_emoji`", + "severity": "error" + }, + { + "line": 674, + "column": 36, + "stop_line": 674, + "stop_column": 40, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@TelegramAdapter.lock_scope` has type `(self: Self@TelegramAdapter) -> str`, which is not assignable to `(self: Self@TelegramAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 705, + "column": 28, + "stop_line": 705, + "stop_column": 37, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | TelegramUser` is not assignable to parameter `user` with type `TelegramUser` in function `TelegramAdapter.to_author`", + "concise_description": "Argument `object | TelegramUser` is not assignable to parameter `user` with type `TelegramUser` in function `TelegramAdapter.to_author`", + "severity": "error" + }, + { + "line": 718, + "column": 25, + "stop_line": 718, + "stop_column": 29, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@TelegramAdapter.lock_scope` has type `(self: Self@TelegramAdapter) -> str`, which is not assignable to `(self: Self@TelegramAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 723, + "column": 27, + "stop_line": 723, + "stop_column": 36, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `str | None` is not assignable to parameter `action_id` with type `str` in function `chat_sdk.types.ActionEvent.__init__`", + "concise_description": "Argument `str | None` is not assignable to parameter `action_id` with type `str` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 783, + "column": 33, + "stop_line": 783, + "stop_column": 37, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@TelegramAdapter.lock_scope` has type `(self: Self@TelegramAdapter) -> str`, which is not assignable to `(self: Self@TelegramAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 784, + "column": 32, + "stop_line": 784, + "stop_column": 36, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 801, + "column": 33, + "stop_line": 801, + "stop_column": 37, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@TelegramAdapter.lock_scope` has type `(self: Self@TelegramAdapter) -> str`, which is not assignable to `(self: Self@TelegramAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@TelegramAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 802, + "column": 32, + "stop_line": 802, + "stop_column": 36, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1239, + "column": 37, + "stop_line": 1239, + "stop_column": 46, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | TelegramUser` is not assignable to parameter `user` with type `TelegramUser` in function `TelegramAdapter.to_author`", + "concise_description": "Argument `object | TelegramUser` is not assignable to parameter `user` with type `TelegramUser` in function `TelegramAdapter.to_author`", + "severity": "error" + }, + { + "line": 1728, + "column": 20, + "stop_line": 1728, + "stop_column": 45, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-return", + "description": "Returned type `object | str` is not assignable to declared return type `str`", + "concise_description": "Returned type `object | str` is not assignable to declared return type `str`", + "severity": "error" + }, + { + "line": 1734, + "column": 30, + "stop_line": 1734, + "stop_column": 55, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter `name` with type `str` in function `chat_sdk.emoji.get_emoji`", + "concise_description": "Argument `object | str` is not assignable to parameter `name` with type `str` in function `chat_sdk.emoji.get_emoji`", + "severity": "error" + }, + { + "line": 1913, + "column": 20, + "stop_line": 1913, + "stop_column": 40, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 1917, + "column": 24, + "stop_line": 1917, + "stop_column": 36, + "path": "src/chat_sdk/adapters/telegram/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 53, + "column": 9, + "stop_line": 53, + "stop_column": 15, + "path": "src/chat_sdk/adapters/telegram/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `TelegramFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `text`, expected `platform_text`", + "concise_description": "Class member `TelegramFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 210, + "column": 33, + "stop_line": 210, + "stop_column": 40, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `inbound` with type `WhatsAppInboundMessage` in function `WhatsAppAdapter._handle_inbound_message`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `inbound` with type `WhatsAppInboundMessage` in function `WhatsAppAdapter._handle_inbound_message`", + "severity": "error" + }, + { + "line": 310, + "column": 36, + "stop_line": 310, + "stop_column": 42, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 315, + "column": 36, + "stop_line": 315, + "stop_column": 40, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`\n Property getter for `Self@WhatsAppAdapter.lock_scope` has type `(self: Self@WhatsAppAdapter) -> str`, which is not assignable to `(self: Self@WhatsAppAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ChatInstance.process_message`", + "severity": "error" + }, + { + "line": 331, + "column": 36, + "stop_line": 331, + "stop_column": 42, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 339, + "column": 86, + "stop_line": 339, + "stop_column": 92, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 341, + "column": 29, + "stop_line": 341, + "stop_column": 35, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 350, + "column": 25, + "stop_line": 350, + "stop_column": 29, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `Self@WhatsAppAdapter.lock_scope` has type `(self: Self@WhatsAppAdapter) -> str`, which is not assignable to `(self: Self@WhatsAppAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 351, + "column": 24, + "stop_line": 351, + "stop_column": 28, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Protocol `Thread` requires attribute `channel_id`", + "concise_description": "Argument `None` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 377, + "column": 36, + "stop_line": 377, + "stop_column": 42, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 398, + "column": 86, + "stop_line": 398, + "stop_column": 92, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 401, + "column": 25, + "stop_line": 401, + "stop_column": 29, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@WhatsAppAdapter.lock_scope` has type `(self: Self@WhatsAppAdapter) -> str`, which is not assignable to `(self: Self@WhatsAppAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 406, + "column": 37, + "stop_line": 406, + "stop_column": 43, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 412, + "column": 27, + "stop_line": 412, + "stop_column": 36, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `str | None` is not assignable to parameter `action_id` with type `str` in function `chat_sdk.types.ActionEvent.__init__`", + "concise_description": "Argument `str | None` is not assignable to parameter `action_id` with type `str` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 433, + "column": 36, + "stop_line": 433, + "stop_column": 42, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 437, + "column": 86, + "stop_line": 437, + "stop_column": 92, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 440, + "column": 25, + "stop_line": 440, + "stop_column": 29, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`\n Property getter for `Self@WhatsAppAdapter.lock_scope` has type `(self: Self@WhatsAppAdapter) -> str`, which is not assignable to `(self: Self@WhatsAppAdapter) -> Literal['channel', 'thread'] | None`, the property getter for `Adapter.lock_scope`", + "concise_description": "Argument `Self@WhatsAppAdapter` is not assignable to parameter `adapter` with type `Adapter` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 445, + "column": 37, + "stop_line": 445, + "stop_column": 43, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 499, + "column": 86, + "stop_line": 499, + "stop_column": 92, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 501, + "column": 29, + "stop_line": 501, + "stop_column": 35, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppInboundMessage` does not have key `from`\n Did you mean `from_`?", + "concise_description": "TypedDict `WhatsAppInboundMessage` does not have key `from`", + "severity": "error" + }, + { + "line": 511, + "column": 24, + "stop_line": 511, + "stop_column": 31, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "`WhatsAppInboundMessage` is not assignable to TypedDict key `message` with type `dict[str, Any]`", + "concise_description": "`WhatsAppInboundMessage` is not assignable to TypedDict key `message` with type `dict[str, Any]`", + "severity": "error" + }, + { + "line": 713, + "column": 87, + "stop_line": 713, + "stop_column": 100, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppCardResultText` does not have key `interactive`", + "concise_description": "TypedDict `WhatsAppCardResultText` does not have key `interactive`", + "severity": "error" + }, + { + "line": 718, + "column": 51, + "stop_line": 718, + "stop_column": 57, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-typed-dict-key", + "description": "TypedDict `WhatsAppCardResultInteractive` does not have key `text`", + "concise_description": "TypedDict `WhatsAppCardResultInteractive` does not have key `text`", + "severity": "error" + }, + { + "line": 843, + "column": 51, + "stop_line": 843, + "stop_column": 76, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, str]` is not assignable to parameter `message` with type `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement` in function `WhatsAppAdapter.post_message`", + "concise_description": "Argument `dict[str, str]` is not assignable to parameter `message` with type `PostableAst | PostableCard | PostableMarkdown | PostableRaw | str | CardElement` in function `WhatsAppAdapter.post_message`", + "severity": "error" + }, + { + "line": 953, + "column": 43, + "stop_line": 953, + "stop_column": 57, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `message` with type `WhatsAppInboundMessage` in function `WhatsAppAdapter._extract_text_content`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `message` with type `WhatsAppInboundMessage` in function `WhatsAppAdapter._extract_text_content`", + "severity": "error" + }, + { + "line": 955, + "column": 47, + "stop_line": 955, + "stop_column": 61, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[str, Any]` is not assignable to parameter `inbound` with type `WhatsAppInboundMessage` in function `WhatsAppAdapter._build_attachments`", + "concise_description": "Argument `dict[str, Any]` is not assignable to parameter `inbound` with type `WhatsAppInboundMessage` in function `WhatsAppAdapter._build_attachments`", + "severity": "error" + }, + { + "line": 1044, + "column": 20, + "stop_line": 1044, + "stop_column": 40, + "path": "src/chat_sdk/adapters/whatsapp/adapter.py", + "code": -2, + "name": "not-async", + "description": "Type `object` is not awaitable", + "concise_description": "Type `object` is not awaitable", + "severity": "error" + }, + { + "line": 231, + "column": 16, + "stop_line": 231, + "stop_column": 21, + "path": "src/chat_sdk/adapters/whatsapp/cards.py", + "code": -2, + "name": "bad-return", + "description": "Returned type `list[object | str]` is not assignable to declared return type `list[str]`", + "concise_description": "Returned type `list[object | str]` is not assignable to declared return type `list[str]`", + "severity": "error" + }, + { + "line": 82, + "column": 9, + "stop_line": 82, + "stop_column": 15, + "path": "src/chat_sdk/adapters/whatsapp/format_converter.py", + "code": -2, + "name": "bad-param-name-override", + "description": "Class member `WhatsAppFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner\n Got parameter name `markdown`, expected `platform_text`", + "concise_description": "Class member `WhatsAppFormatConverter.to_ast` overrides parent class `BaseFormatConverter` in an inconsistent manner", + "severity": "error" + }, + { + "line": 772, + "column": 61, + "stop_line": 772, + "stop_column": 65, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@Chat` is not assignable to parameter `chat` with type `_ChatSingleton | None` in function `chat_sdk.thread.ThreadImpl.from_json`", + "concise_description": "Argument `Self@Chat` is not assignable to parameter `chat` with type `_ChatSingleton | None` in function `chat_sdk.thread.ThreadImpl.from_json`", + "severity": "error" + }, + { + "line": 774, + "column": 62, + "stop_line": 774, + "stop_column": 66, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `Self@Chat` is not assignable to parameter `chat` with type `_ChatSingleton | None` in function `chat_sdk.channel.ChannelImpl.from_json`", + "concise_description": "Argument `Self@Chat` is not assignable to parameter `chat` with type `_ChatSingleton | None` in function `chat_sdk.channel.ChannelImpl.from_json`", + "severity": "error" + }, + { + "line": 1049, + "column": 13, + "stop_line": 1053, + "stop_column": 14, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `_ChannelImplConfigForChat` is not assignable to parameter `config` with type `_ChannelImplConfigForThread | _ChannelImplConfigLazy | _ChannelImplConfigWithAdapter` in function `chat_sdk.channel.ChannelImpl.__init__`", + "concise_description": "Argument `_ChannelImplConfigForChat` is not assignable to parameter `config` with type `_ChannelImplConfigForThread | _ChannelImplConfigLazy | _ChannelImplConfigWithAdapter` in function `chat_sdk.channel.ChannelImpl.__init__`", + "severity": "error" + }, + { + "line": 1071, + "column": 21, + "stop_line": 1071, + "stop_column": 28, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ChannelImpl` is not assignable to parameter `channel` with type `Channel` in function `chat_sdk.types.SlashCommandEvent.__init__`\n `ChannelImpl.set_state` has type `(self: ChannelImpl, new_state: dict[str, Any], *, replace: bool = False) -> Coroutine[Unknown, Unknown, None]`, which is not assignable to `(self: ChannelImpl, state: dict[str, Any], *, replace: bool = False) -> Coroutine[Unknown, Unknown, None]`, the type of `Channel.set_state`", + "concise_description": "Argument `ChannelImpl` is not assignable to parameter `channel` with type `Channel` in function `chat_sdk.types.SlashCommandEvent.__init__`", + "severity": "error" + }, + { + "line": 1244, + "column": 20, + "stop_line": 1244, + "stop_column": 26, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ThreadImpl | None` is not assignable to parameter `thread` with type `Thread | None` in function `chat_sdk.types.ActionEvent.__init__`", + "concise_description": "Argument `ThreadImpl | None` is not assignable to parameter `thread` with type `Thread | None` in function `chat_sdk.types.ActionEvent.__init__`", + "severity": "error" + }, + { + "line": 1306, + "column": 20, + "stop_line": 1306, + "stop_column": 26, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `ThreadImpl` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`\n Property getter for `ThreadImpl.channel` has type `(self: ThreadImpl) -> ChannelImpl`, which is not assignable to `(self: ThreadImpl) -> Channel`, the property getter for `Thread.channel`", + "concise_description": "Argument `ThreadImpl` is not assignable to parameter `thread` with type `Thread` in function `chat_sdk.types.ReactionEvent.__init__`", + "severity": "error" + }, + { + "line": 1374, + "column": 13, + "stop_line": 1378, + "stop_column": 14, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `_ChannelImplConfigForChat` is not assignable to parameter `config` with type `_ChannelImplConfigForThread | _ChannelImplConfigLazy | _ChannelImplConfigWithAdapter` in function `chat_sdk.channel.ChannelImpl.__init__`", + "concise_description": "Argument `_ChannelImplConfigForChat` is not assignable to parameter `config` with type `_ChannelImplConfigForThread | _ChannelImplConfigLazy | _ChannelImplConfigWithAdapter` in function `chat_sdk.channel.ChannelImpl.__init__`", + "severity": "error" + }, + { + "line": 1429, + "column": 21, + "stop_line": 1436, + "stop_column": 14, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "not-async", + "description": "Type `Literal['channel']` is not awaitable", + "concise_description": "Type `Literal['channel']` is not awaitable", + "severity": "error" + }, + { + "line": 1429, + "column": 21, + "stop_line": 1436, + "stop_column": 14, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "not-async", + "description": "Type `Literal['thread']` is not awaitable", + "concise_description": "Type `Literal['thread']` is not awaitable", + "severity": "error" + }, + { + "line": 1793, + "column": 17, + "stop_line": 1793, + "stop_column": 59, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "not-async", + "description": "Type `None` is not awaitable", + "concise_description": "Type `None` is not awaitable", + "severity": "error" + }, + { + "line": 1818, + "column": 17, + "stop_line": 1818, + "stop_column": 60, + "path": "src/chat_sdk/chat.py", + "code": -2, + "name": "not-async", + "description": "Type `None` is not awaitable", + "concise_description": "Type `None` is not awaitable", + "severity": "error" + }, + { + "line": 194, + "column": 50, + "stop_line": 194, + "stop_column": 57, + "path": "src/chat_sdk/shared/base_format_converter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `dict[Any, Any]` is not assignable to parameter `card` with type `CardElement` in function `chat_sdk.cards.card_to_fallback_text`", + "concise_description": "Argument `dict[Any, Any]` is not assignable to parameter `card` with type `CardElement` in function `chat_sdk.cards.card_to_fallback_text`", + "severity": "error" + }, + { + "line": 68, + "column": 29, + "stop_line": 68, + "stop_column": 53, + "path": "src/chat_sdk/shared/card_utils.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter with type `str`", + "concise_description": "Argument `object | str` is not assignable to parameter with type `str`", + "severity": "error" + }, + { + "line": 70, + "column": 32, + "stop_line": 70, + "stop_column": 54, + "path": "src/chat_sdk/shared/card_utils.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `object | str` is not assignable to parameter with type `str`", + "concise_description": "Argument `object | str` is not assignable to parameter with type `str`", + "severity": "error" + }, + { + "line": 72, + "column": 93, + "stop_line": 72, + "stop_column": 118, + "path": "src/chat_sdk/shared/card_utils.py", + "code": -2, + "name": "not-iterable", + "description": "Type `object` is not iterable", + "concise_description": "Type `object` is not iterable", + "severity": "error" + }, + { + "line": 76, + "column": 90, + "stop_line": 76, + "stop_column": 115, + "path": "src/chat_sdk/shared/card_utils.py", + "code": -2, + "name": "not-iterable", + "description": "Type `object` is not iterable", + "concise_description": "Type `object` is not iterable", + "severity": "error" + }, + { + "line": 78, + "column": 39, + "stop_line": 78, + "stop_column": 63, + "path": "src/chat_sdk/shared/card_utils.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.cards.table_element_to_ascii`", + "concise_description": "Argument `list[str] | object` is not assignable to parameter `headers` with type `list[str]` in function `chat_sdk.cards.table_element_to_ascii`", + "severity": "error" + }, + { + "line": 78, + "column": 65, + "stop_line": 78, + "stop_column": 86, + "path": "src/chat_sdk/shared/card_utils.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.cards.table_element_to_ascii`", + "concise_description": "Argument `list[list[str]] | object` is not assignable to parameter `rows` with type `list[list[str]]` in function `chat_sdk.cards.table_element_to_ascii`", + "severity": "error" + }, + { + "line": 193, + "column": 49, + "stop_line": 193, + "stop_column": 53, + "path": "src/chat_sdk/shared/mock_adapter.py", + "code": -2, + "name": "bad-argument-type", + "description": "Argument `None` is not assignable to parameter `thread_id` with type `str` in function `chat_sdk.types.RawMessage.__init__`", + "concise_description": "Argument `None` is not assignable to parameter `thread_id` with type `str` in function `chat_sdk.types.RawMessage.__init__`", + "severity": "error" + }, + { + "line": 109, + "column": 20, + "stop_line": 109, + "stop_column": 45, + "path": "src/chat_sdk/state/redis.py", + "code": -2, + "name": "missing-import", + "description": "Cannot find module `redis.asyncio`\n Looked in these locations (from config in `/home/user/chat-sdk-python/pyproject.toml`):\n Import root (inferred from project layout): \"/home/user/chat-sdk-python/src\"\n Site package path queried from interpreter: [\"/home/user/chat-sdk-python/.venv/lib/python3.11/site-packages\", \"/home/user/chat-sdk-python/src\"]", + "concise_description": "Cannot find module `redis.asyncio`", + "severity": "error" + }, + { + "line": 578, + "column": 27, + "stop_line": 578, + "stop_column": 37, + "path": "src/chat_sdk/thread.py", + "code": -2, + "name": "missing-attribute", + "description": "Object of class `PlanUpdateChunk` has no attribute `text`\nObject of class `TaskUpdateChunk` has no attribute `text`", + "concise_description": "Object of class `PlanUpdateChunk` has no attribute `text`\nObject of class `TaskUpdateChunk` has no attribute `text`", + "severity": "error" + }, + { + "line": 975, + "column": 23, + "stop_line": 975, + "stop_column": 27, + "path": "src/chat_sdk/thread.py", + "code": -2, + "name": "invalid-yield", + "description": "Yielded type `dict[Any, Any]` is not assignable to declared yield type `MarkdownTextChunk | PlanUpdateChunk | TaskUpdateChunk | str`", + "concise_description": "Yielded type `dict[Any, Any]` is not assignable to declared yield type `MarkdownTextChunk | PlanUpdateChunk | TaskUpdateChunk | str`", + "severity": "error" + } + ] +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2c9fbfd..3c89d1b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,4 +74,46 @@ dev = [ "aiohttp>=3.9", "pyjwt[crypto]>=2.8", "ruff>=0.4.0", + "pyrefly>=0.61", ] + +# --------------------------------------------------------------------------- +# Pyrefly type checker configuration +# --------------------------------------------------------------------------- +# Baseline workflow: +# Check against baseline: uv run pyrefly check --baseline=.pyrefly-baseline.json +# Refresh baseline: uv run pyrefly check --baseline=.pyrefly-baseline.json --update-baseline +# --------------------------------------------------------------------------- + +[tool.pyrefly] +project-includes = ["src"] +project-excludes = ["**/__pycache__", "**/.pytest_cache"] + +# Without disabling heuristics pyrefly skips src/ because of our layout. +disable-project-excludes-heuristics = true +use-ignore-files = false + +python-version = "3.11" +python-platform = "linux" + +# Optional adapter deps that aren't in the default install. Treating them as +# Any keeps pyrefly from flagging missing-import every time we lazy-load one. +replace-imports-with-any = [ + # Slack + "slack_sdk", + # Discord + "nacl", + # Google Chat + "google", + # State backends + "redis", + "asyncpg", + # HTTP clients used in lazy paths + "httpx", + # GitHub App auth + "jwt", +] + +[tool.pyrefly.errors] +# Default severity. Known issues tracked in .pyrefly-baseline.json — +# new errors fail CI, existing ones are allowed. From 0cc5d11e5dbf3c7fb7af2ed4cd0f6e6375dbbcd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:23:00 +0000 Subject: [PATCH 06/40] ci: fix lint job cache + add least-privilege permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small CI fixes: 1. The new Lint & Type Check job failed on its first run because `astral-sh/setup-uv@v4`'s `enable-cache: true` and the `actions/cache` step both key off `uv.lock`, which we gitignore. Dropped both cache layers — matches the existing test.yml pattern and removes the hashFiles('uv.lock') failure. 2. github-advanced-security flagged the workflows for missing GITHUB_TOKEN permissions. Added `permissions: { contents: read }` at the workflow level to both lint.yml and test.yml — minimum needed for checkout + tests. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .github/workflows/lint.yml | 15 ++------------- .github/workflows/test.yml | 3 +++ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 12c6856..4b2d0d3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,8 +26,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true -env: - UV_CACHE_DIR: /tmp/.uv-cache +permissions: + contents: read jobs: lint: @@ -40,17 +40,6 @@ jobs: fetch-depth: 1 - uses: astral-sh/setup-uv@e4db8464a088ece1b920f60402e813ea4de65b8f # v4 - with: - enable-cache: true - - - name: Restore uv cache - uses: actions/cache@v4 - with: - path: /tmp/.uv-cache - key: uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} - restore-keys: | - uv-${{ runner.os }}-${{ hashFiles('uv.lock') }} - uv-${{ runner.os }} - name: Install dependencies run: uv sync --group dev diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64acdc5..db6f4d0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest From 607088421bd09ccb33eb7a95d4a8ae9b705b7586 Mon Sep 17 00:00:00 2001 From: patrick-chinchill Date: Thu, 16 Apr 2026 18:29:56 -0700 Subject: [PATCH 07/40] Potential fix for pull request finding 'CodeQL / Workflow does not contain permissions' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Signed-off-by: patrick-chinchill --- .github/workflows/lint.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 4b2d0d3..a5ecd53 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,6 +22,9 @@ on: - '.github/workflows/lint.yml' workflow_dispatch: +permissions: + contents: read + concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true From 6c3871c64d85351ef9c7a3b23b07f6b9ae4b27ec Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:53:58 +0000 Subject: [PATCH 08/40] fix(gchat,serde): round-trip links; sync adapter_name on rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex P1/P2 review findings: P1 (gchat): commit 4cc87dd in the 4.26 sync switched link rendering to Google Chat's custom-label syntax but didn't teach to_ast() and extract_plain_text() to parse it back. Messages posted with [label](url) round-tripped to raw "" text with no link node, breaking downstream handlers that expect structured links or clean plain text. Added the inverse regex in both methods. P2 (serde): ThreadImpl.to_json() / ChannelImpl.to_json() preferred _adapter_name over self.adapter.name to preserve the original name through standalone-reviver round-trips. But when a caller explicitly binds a different adapter via from_json(data, adapter=X), the serialized output kept the stale name from the payload while runtime calls used X — a later deserialize could resolve the wrong adapter or fail. Now from_json() sets _adapter_name = adapter.name when an explicit adapter is bound so serialize and runtime stay in sync. Regression tests: - tests/test_gchat_format_extended.py: link parse + plain-text strip - tests/test_serialization.py: thread adapter_name sync - tests/test_channel_faithful.py: channel adapter_name sync https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 15 ++++++--- src/chat_sdk/channel.py | 3 ++ src/chat_sdk/thread.py | 3 ++ tests/test_channel_faithful.py | 18 +++++++++++ tests/test_gchat_format_extended.py | 32 +++++++++++++++++++ tests/test_serialization.py | 22 +++++++++++++ 6 files changed, 89 insertions(+), 4 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 7982204..7bc6d88 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -42,6 +42,11 @@ def to_ast(self, gchat_text: str) -> Root: """ markdown = gchat_text + # Google Chat custom link syntax -> [text](url). Must run + # before bold/strikethrough so the `|` inside a link label isn't + # matched by those patterns. + markdown = re.sub(r"<(https?://[^|\s>]+)\|([^>]+)>", r"[\2](\1)", markdown) + # Bold: *text* -> **text** markdown = re.sub(r"(? str: """ # Remove code blocks result = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), text) - # Remove inline code + # Inline code result = re.sub(r"`([^`]+)`", r"\1", result) - # Remove bold markers (*text*) + # Google Chat custom link syntax: -> text + result = re.sub(r"]+\|([^>]+)>", r"\1", result) + # Bold markers (*text*) result = re.sub(r"\*([^*]+)\*", r"\1", result) - # Remove italic markers (_text_) + # Italic markers (_text_) result = re.sub(r"_([^_]+)_", r"\1", result) - # Remove strikethrough markers (~text~) + # Strikethrough markers (~text~) result = re.sub(r"~([^~]+)~", r"\1", result) return result.strip() diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 4d249e9..99b6815 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -428,6 +428,9 @@ def from_json( ) if adapter is not None: channel._adapter = adapter + # Keep _adapter_name in sync with the explicit adapter so + # to_json() doesn't serialize a stale name. + channel._adapter_name = adapter.name elif chat is not None: if channel._adapter_name: resolved = chat.get_adapter(channel._adapter_name) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index c0a4965..8d8c105 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -779,6 +779,9 @@ def from_json( ) if adapter is not None: thread._adapter = adapter + # Keep _adapter_name in sync with the explicit adapter so + # to_json() doesn't serialize a stale name after rebind. + thread._adapter_name = adapter.name elif chat is not None: if thread._adapter_name: resolved = chat.get_adapter(thread._adapter_name) diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index 1d44c8f..981704c 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -651,6 +651,24 @@ def test_should_deserialize_from_json(self): assert channel.is_dm is False assert channel.adapter is adapter + def test_should_sync_adapter_name_when_explicit_adapter_is_bound(self): + """from_json(data, adapter=X) must update _adapter_name to X.name so + to_json() doesn't serialize a stale name. Regression for a P2 raised + in review.""" + from chat_sdk.testing import create_mock_adapter as _create + + renamed_adapter = _create("teams") + json_data = { + "_type": "chat:Channel", + "id": "C123", + "adapter_name": "slack", # different from the bound adapter + "is_dm": False, + } + channel = ChannelImpl.from_json(json_data, renamed_adapter) + + assert channel.adapter.name == "teams" + assert channel.to_json()["adapterName"] == "teams" + # =========================================================================== # deriveChannelId (tested in channel.test.ts alongside ChannelImpl) diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index da4d615..d114a6a 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -55,6 +55,30 @@ def test_inline_code_to_ast(self): ast = converter.to_ast("Use `const x = 1`") assert ast["type"] == "root" + def test_gchat_custom_link_parses_back_to_link_node(self): + """Round-trip guard: `` emitted by from_ast must parse back + to a link node so downstream handlers see structured links, not raw + angle-bracket text. Regression test for a P1 raised in review.""" + converter = _converter() + ast = converter.to_ast("See for more") + + # Walk the AST looking for a link node with the expected URL and label. + found = False + + def _walk(node: object) -> None: + nonlocal found + if isinstance(node, dict): + if node.get("type") == "link" and node.get("url") == "https://example.com": + children = node.get("children", []) + text = "".join(c.get("value", "") for c in children if isinstance(c, dict)) + if text == "Example": + found = True + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + assert found, "Expected a link node with url='https://example.com' and text='Example'" + # --------------------------------------------------------------------------- # from_ast: AST -> Google Chat format @@ -247,6 +271,14 @@ def test_strips_code_blocks(self): result = converter.extract_plain_text("```\nsome code\n```") assert "some code" in result + def test_strips_gchat_custom_link_to_label(self): + """ should reduce to just the label text.""" + converter = _converter() + assert ( + converter.extract_plain_text("See for details") + == "See Example Site for details" + ) + # --------------------------------------------------------------------------- # render_postable diff --git a/tests/test_serialization.py b/tests/test_serialization.py index bcabe87..6b35968 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -579,6 +579,28 @@ def test_should_reconstruct_thread_from_json(self, mock_adapter, mock_state): assert thread.is_dm is False assert thread.adapter.name == "slack" + def test_should_sync_adapter_name_when_explicit_adapter_is_bound(self, mock_state): + """from_json(data, adapter=X) must update _adapter_name to X.name so + to_json() doesn't serialize a stale name that refers to a different + adapter than what's actually bound. Regression for a P2 raised in + review.""" + from chat_sdk.testing import create_mock_adapter + + renamed_adapter = create_mock_adapter("teams") + data = { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channel_id": "C123", + "is_dm": False, + "adapter_name": "slack", # different from the bound adapter + } + thread = ThreadImpl.from_json(data, adapter=renamed_adapter) + + # Runtime uses the bound adapter... + assert thread.adapter.name == "teams" + # ...and re-serialization reflects that, not the stale "slack" name. + assert thread.to_json()["adapterName"] == "teams" + def test_should_reconstruct_dm_thread(self, mock_adapter, mock_state): data = { "_type": "chat:Thread", From 101846f2fd9fb1321e1ee2b2a4af53b86f94fbbf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:55:11 +0000 Subject: [PATCH 09/40] ci: dedupe permissions block in lint.yml Copilot Autofix added a permissions block before `concurrency` without noticing 0cc5d11 had already added one after `concurrency`. Remove the redundant second block; Copilot's position (before concurrency) is the more conventional GitHub Actions placement. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .github/workflows/lint.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a5ecd53..624f390 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -29,9 +29,6 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} cancel-in-progress: true -permissions: - contents: read - jobs: lint: name: Lint & Type Check From 0e2b07cb74904e0599b2843127bea0fcfb3a72a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:55:42 +0000 Subject: [PATCH 10/40] chore: gitignore .claude/ (subagent worktree root) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same as the entry on the type-cleanup branch — keeps the parallel-subagent worktree directory out of git. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b2e8d85..ccdf507 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ build/ .pytest_cache/ .mypy_cache/ uv.lock +.claude/ From 2ea626be65d0a7901ffac63bb3f358fe7dd42ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 01:57:30 +0000 Subject: [PATCH 11/40] docs: mark c4e3bee gchat/serde fixes as divergences from upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Codex P1/P2 fixes in c4e3bee were introduced without checking whether upstream handles these cases — turns out upstream has the same latent bugs in both places: - packages/adapter-gchat/src/markdown.ts toAst() doesn't parse back to a link node. - packages/chat/src/thread.ts fromJSON(json, adapter?) sets _adapter but leaves _adapterName at the payload value. So our fixes are intentional divergences, not ports. Add both to the Known Non-Parity table and call them out in the CHANGELOG so we don't re-discover them on the next sync. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CHANGELOG.md | 2 ++ docs/UPSTREAM_SYNC.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2474052..d7d7234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ Synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat). ### Python-specific (divergence from upstream 4.26) - **Fallback streaming clears stranded placeholders**: when a stream produces only whitespace with the default placeholder enabled, the final edit replaces `"..."` with `" "` so the message doesn't render as permanently loading. Upstream 4.26 intentionally leaves the placeholder visible to avoid empty-edit API calls; we issue one final edit to `" "` instead. Documented under [Known Non-Parity](docs/UPSTREAM_SYNC.md#known-non-parity-with-typescript-sdk). +- **Google Chat `` round-trip**: upstream 4.26 emits Google Chat's custom-label link syntax in the outgoing direction but doesn't parse it back in `to_ast()` / `extract_plain_text()`. A `[label](url)` posted through the gchat adapter would round-trip back as raw `""` text with no link node, breaking downstream handlers. We added the inverse regex to close the round-trip. Documented under Known Non-Parity. +- **`from_json(data, adapter=X)` syncs `_adapter_name`**: upstream leaves `_adapterName` at the payload value even when an explicit adapter is bound, so `to_json()` can emit a stale name that refers to a different adapter than what runtime calls use. We update `_adapter_name = adapter.name` on explicit rebind so serialize and runtime stay consistent. Documented under Known Non-Parity. ## 0.4.25 (2026-04-10) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 6fb870b..d7ac96a 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -395,6 +395,8 @@ stay explicit instead of being rediscovered in code review. | PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history | | Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers | | Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | +| Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text with no link node. We close the round-trip. | +| `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | ### Platform-specific gaps From bf7456355fdeaf54f7145ee2e71b266584e19f53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 02:42:56 +0000 Subject: [PATCH 12/40] fix(gchat): accept any URL scheme in round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 caught a gap in c4e3bee: _node_to_gchat emits for every link node regardless of scheme, but the regex I added to to_ast() / extract_plain_text() only matched http(s)://. So [Email](mailto:test@example.com) would render as and then round-trip back as raw text — non-HTTP links still lost their link nodes and plain-text extraction on the way back. Widen both regexes to accept any RFC 3986 scheme ([a-zA-Z][a-zA-Z0-9+.-]*:), covering mailto, tel, ftp, etc. Add a regression that exercises three representative schemes. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 9 +++--- tests/test_gchat_format_extended.py | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 7bc6d88..d7c38b3 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -44,8 +44,9 @@ def to_ast(self, gchat_text: str) -> Root: # Google Chat custom link syntax -> [text](url). Must run # before bold/strikethrough so the `|` inside a link label isn't - # matched by those patterns. - markdown = re.sub(r"<(https?://[^|\s>]+)\|([^>]+)>", r"[\2](\1)", markdown) + # matched by those patterns. Accepts any RFC 3986 scheme, not just + # http(s), so mailto:/tel:/etc. round-trip cleanly. + markdown = re.sub(r"<([a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+)\|([^>]+)>", r"[\2](\1)", markdown) # Bold: *text* -> **text** markdown = re.sub(r"(? str: result = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), text) # Inline code result = re.sub(r"`([^`]+)`", r"\1", result) - # Google Chat custom link syntax: -> text - result = re.sub(r"]+\|([^>]+)>", r"\1", result) + # Google Chat custom link syntax: -> text (any RFC 3986 scheme) + result = re.sub(r"<[a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+\|([^>]+)>", r"\1", result) # Bold markers (*text*) result = re.sub(r"\*([^*]+)\*", r"\1", result) # Italic markers (_text_) diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index d114a6a..4d2abf9 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -79,6 +79,34 @@ def _walk(node: object) -> None: _walk(ast) assert found, "Expected a link node with url='https://example.com' and text='Example'" + def test_gchat_custom_link_parses_non_http_schemes(self): + """mailto:/tel:/etc. emit in from_ast; to_ast must accept + any RFC 3986 scheme, not just http(s).""" + converter = _converter() + for url, label in [ + ("mailto:test@example.com", "Email"), + ("tel:+15551234", "Call"), + ("ftp:files.example.com", "Files"), + ]: + ast = converter.to_ast(f"Contact <{url}|{label}> for details") + found = False + + def _walk(node: object, _url: str = url, _label: str = label) -> None: + nonlocal found + if isinstance(node, dict): + if node.get("type") == "link" and node.get("url") == _url: + children = node.get("children", []) + text = "".join(c.get("value", "") for c in children if isinstance(c, dict)) + if text == _label: + found = True + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + assert found, f"Expected a link node for url={url!r} label={label!r}" + # And extract_plain_text should reduce to just the label + assert converter.extract_plain_text(f"<{url}|{label}>") == label + # --------------------------------------------------------------------------- # from_ast: AST -> Google Chat format From dfef313aa568d60a1e0768a3987704e3feff04d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 02:56:28 +0000 Subject: [PATCH 13/40] fix(gchat): fall back to parenthesized form when link label contains >/|/newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: Google Chat's syntax has no way to escape the `|` or `>` delimiters inside the label, and a newline would break the single-line form too. Emitting these characters produces malformed output — both Google Chat's own renderer and our round-trip regex stop at the first `>` / `|`, truncating the label. Fall back to the pre-4.26 `text (url)` form in those cases. The URL is still auto-detected as a link by Google Chat; the label text is preserved intact; and the output round-trips cleanly through our parsers (just without a structured link node for that edge case, which matches upstream behavior for any non-`` link form). Regression test in test_gchat_format_extended.py covers `a > b`, `a | b`, and `a\nb` labels. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 7 +++++++ tests/test_gchat_format_extended.py | 13 +++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index d7c38b3..0a8d88b 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -115,6 +115,13 @@ def _node_to_gchat(self, node: Content) -> str: url = node.get("url", "") if link_text == url: return url + # Labels containing the `|` or `>` delimiters, or a newline, can't + # be emitted safely in form — Google Chat (and our own + # round-trip regex) stops at the first `>` / `|`. Fall back to + # plain `text (url)` so the label text is preserved and Google + # Chat's auto-link detection still makes the URL clickable. + if "|" in link_text or ">" in link_text or "\n" in link_text: + return f"{link_text} ({url})" return f"<{url}|{link_text}>" if node_type == "heading": diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 4d2abf9..4a387a2 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -167,6 +167,19 @@ def test_should_preserve_custom_link_labels_in_posted_messages(self): result = converter.from_markdown("[Click here](https://example.com)") assert result == "" + def test_falls_back_to_parenthesized_form_when_label_contains_reserved_chars(self): + """Labels containing `>` / `|` / newline would produce malformed + `` output (Google Chat + our own regex stop at the first + `>` or `|`), so fall back to plain `text (url)` so the label is + preserved intact and the URL is still auto-detected as a link.""" + converter = _converter() + for label in ["a > b", "a | b", "a\nb"]: + result = converter.from_markdown(f"[{label}](https://example.com)") + assert result == f"{label} (https://example.com)" + # Sanity: the safe-labels path still uses . + safe = converter.from_markdown("[ok](https://example.com)") + assert safe == "" + def test_blockquote(self): converter = _converter() ast = converter.to_ast("> quoted text") From 139bb6300bb6b5ca11f286ac6e7d29d986424adf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:00:19 +0000 Subject: [PATCH 14/40] docs: record the gchat reserved-char fallback as a Python divergence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit dfef313 added a fallback from to "text (url)" when the label contains |, >, or newline — upstream emits the malformed b> form verbatim. Add the divergence to both the Known Non-Parity table and the 0.4.26 CHANGELOG entry so we don't rediscover it next sync. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CHANGELOG.md | 1 + docs/UPSTREAM_SYNC.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d7234..f6c8bf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat). - **Fallback streaming clears stranded placeholders**: when a stream produces only whitespace with the default placeholder enabled, the final edit replaces `"..."` with `" "` so the message doesn't render as permanently loading. Upstream 4.26 intentionally leaves the placeholder visible to avoid empty-edit API calls; we issue one final edit to `" "` instead. Documented under [Known Non-Parity](docs/UPSTREAM_SYNC.md#known-non-parity-with-typescript-sdk). - **Google Chat `` round-trip**: upstream 4.26 emits Google Chat's custom-label link syntax in the outgoing direction but doesn't parse it back in `to_ast()` / `extract_plain_text()`. A `[label](url)` posted through the gchat adapter would round-trip back as raw `""` text with no link node, breaking downstream handlers. We added the inverse regex to close the round-trip. Documented under Known Non-Parity. - **`from_json(data, adapter=X)` syncs `_adapter_name`**: upstream leaves `_adapterName` at the payload value even when an explicit adapter is bound, so `to_json()` can emit a stale name that refers to a different adapter than what runtime calls use. We update `_adapter_name = adapter.name` on explicit rebind so serialize and runtime stay consistent. Documented under Known Non-Parity. +- **Google Chat link labels with `|` / `>` / newline**: Google Chat's `` syntax has no escape for `|` or `>`. Upstream emits ` b>` verbatim, which truncates the label on the platform and can't round-trip back. We fall back to the pre-4.26 `text (url)` form whenever the label contains one of those reserved characters — the URL is still auto-linked by the platform and the label stays intact. Documented under Known Non-Parity. ## 0.4.25 (2026-04-10) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index d7ac96a..149380c 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -397,6 +397,7 @@ stay explicit instead of being rediscovered in code review. | Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | | Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text with no link node. We close the round-trip. | | `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | +| Google Chat link labels with `\|` / `>` / newline | Fall back to `text (url)` when the label contains reserved delimiters | Always ``, producing malformed output | Google Chat's `` has no escape for `\|` or `>`; emitting either character truncates the label on the platform and breaks our own round-trip regexes. Upstream emits the malformed form; we fall back to the pre-4.26 `text (url)` form in that narrow case so the label stays intact and the URL still auto-links. | ### Platform-specific gaps From c72a9435cedc00d828991ccf9153d5a4db5a5a1c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:07:15 +0000 Subject: [PATCH 15/40] docs: codify divergence policy + add in-code breadcrumbs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Divergence Policy section to docs/UPSTREAM_SYNC.md with: - Criteria for when to diverge (data loss / malformed wire / hard UX failure; cosmetic issues stay on parity). - Required workflow (file upstream issue, diverge commit prefix, add non-parity row, in-code breadcrumb, regression test, changelog entry). - Review signal (sync PR title convention, budget of max 2 divergences per sync before escalating). Retroactively add `# Divergence from upstream — see docs/UPSTREAM_SYNC.md` breadcrumbs at the four divergence sites introduced in this sync: - src/chat_sdk/thread.py `_adapter_name = adapter.name` in from_json - src/chat_sdk/channel.py same in ChannelImpl.from_json - src/chat_sdk/adapters/google_chat/format_converter.py to_ast() custom-link regex - src/chat_sdk/adapters/google_chat/format_converter.py extract_plain_text() custom-link regex - src/chat_sdk/adapters/google_chat/format_converter.py reserved-char fallback branch in _node_to_gchat The placeholder-clear divergence in thread._fallback_stream already had a full multi-line explanation; leaving that alone. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/UPSTREAM_SYNC.md | 49 +++++++++++++++++++ .../adapters/google_chat/format_converter.py | 3 ++ src/chat_sdk/channel.py | 1 + src/chat_sdk/thread.py | 1 + 4 files changed, 54 insertions(+) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 149380c..a5aee45 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -72,6 +72,55 @@ tests. If upstream tests lock in inconsistent behavior, choose one of: - **Preserve parity** and document the inconsistency in the non-parity section below - **Intentionally diverge** and document the divergence in the non-parity section +## Divergence Policy + +Every divergence from upstream has a cost: merge conflicts on future syncs, +cross-SDK state drift, and a gradual slide from "port" toward "fork". Follow +the rules below before adding one. + +### When to diverge + +1. **Default: preserve parity.** Matching upstream behavior — even buggy — + reduces merge conflicts and keeps cross-SDK state predictable. If the + behavior is cosmetic or stylistic, preserve parity and move on. +2. **Diverge only when upstream** causes one of: + - **Data loss or corruption** (e.g. dropping fields on round-trip). + - **Malformed wire output** the platform itself mis-renders. + - **Hard UX failure with no workaround** (e.g. stuck loading state + that users can't clear). +3. **Before diverging**, open an issue upstream + ([vercel/chat](https://github.com/vercel/chat/issues)) linking the bug. + If upstream accepts and fixes it, delete the divergence on the next sync. +4. **Budget**: a sync PR that accumulates **more than 2 divergences** is a + signal — escalate to a design discussion ("is this still a port?") + before landing quietly. + +### How to land a divergence + +1. **Commit prefix**: use `diverge(scope): ...`, not `fix:` — `fix:` implies + parity with upstream's intent. +2. **Add a row to the [Known Non-Parity](#known-non-parity-with-typescript-sdk) + table** with: Python behavior, TS behavior, rationale, and upstream + issue link (if filed). +3. **Drop a one-line breadcrumb at the divergence site**: + ```python + # Divergence from upstream — see docs/UPSTREAM_SYNC.md + ``` + So a future porter doesn't delete the code thinking it's drift. +4. **Add a regression test** that fails if someone "fixes" the divergence + back to upstream's behavior. The test's docstring should cite the reason. +5. **CHANGELOG entry** under a "Python-specific (divergence from upstream)" + subsection. + +### Review signal + +- **Sync PR titles**: `sync: upstream v` (not a branch name). Reviewers + scanning the PR list need to see "this is a sync" at a glance. +- **Divergence commits are separate** from the sync commit. Don't bundle a + divergence into `sync: upstream v...`; split it into its own + `diverge(scope): ...` commit with the non-parity table update in the same + commit. + ## How to Diff Upstream Changes ```bash diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 0a8d88b..43e14b2 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -42,6 +42,7 @@ def to_ast(self, gchat_text: str) -> Root: """ markdown = gchat_text + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Google Chat custom link syntax -> [text](url). Must run # before bold/strikethrough so the `|` inside a link label isn't # matched by those patterns. Accepts any RFC 3986 scheme, not just @@ -66,6 +67,7 @@ def extract_plain_text(self, text: str) -> str: result = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), text) # Inline code result = re.sub(r"`([^`]+)`", r"\1", result) + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Google Chat custom link syntax: -> text (any RFC 3986 scheme) result = re.sub(r"<[a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+\|([^>]+)>", r"\1", result) # Bold markers (*text*) @@ -115,6 +117,7 @@ def _node_to_gchat(self, node: Content) -> str: url = node.get("url", "") if link_text == url: return url + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Labels containing the `|` or `>` delimiters, or a newline, can't # be emitted safely in form — Google Chat (and our own # round-trip regex) stops at the first `>` / `|`. Fall back to diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 99b6815..c812862 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -428,6 +428,7 @@ def from_json( ) if adapter is not None: channel._adapter = adapter + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Keep _adapter_name in sync with the explicit adapter so # to_json() doesn't serialize a stale name. channel._adapter_name = adapter.name diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 8d8c105..b366b42 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -779,6 +779,7 @@ def from_json( ) if adapter is not None: thread._adapter = adapter + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Keep _adapter_name in sync with the explicit adapter so # to_json() doesn't serialize a stale name after rebind. thread._adapter_name = adapter.name From 8dd34d1c3c92738844f93d674d19a030bafc44e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:13:40 +0000 Subject: [PATCH 16/40] address coderabbit review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [critical] thread._fallback_stream: wrap the placeholder-clear edit_message(" ") in try/except so Telegram (which raises ValidationError on whitespace-only text) logs and falls back to upstream's stranded-placeholder behavior instead of propagating a crash up through thread.post(). - [major] pyrefly python-version 3.11 -> 3.10 to match requires-python. Otherwise type-check target drifts ahead of the package's claimed support floor. - [major] test_chat_faithful.py: the fallback-streaming test now verifies the final posted markdown is "Hi", not just that some post happened on the right thread. - [minor] test_thread_faithful.py: three "no empty content" tests were vacuous — they iterated PostableMarkdown calls inside an isinstance guard but never asserted that any calls happened, so the tests passed even if the SDK emitted nothing. Now assert at least one matching call before checking content. - [nit] channel.py / thread.py from_json: replace `or` truthiness chain on camel-to-snake fallback keys with explicit None checks. `""` is a valid-but-falsy value that shouldn't silently fall through to the alias. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- pyproject.toml | 2 +- src/chat_sdk/channel.py | 13 ++++++++--- src/chat_sdk/thread.py | 44 +++++++++++++++++++++++++---------- tests/test_chat_faithful.py | 6 +++-- tests/test_thread_faithful.py | 18 +++++++------- 5 files changed, 56 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3c89d1b..dff361b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -93,7 +93,7 @@ project-excludes = ["**/__pycache__", "**/.pytest_cache"] disable-project-excludes-heuristics = true use-ignore-files = false -python-version = "3.11" +python-version = "3.10" python-platform = "linux" # Optional adapter deps that aren't in the default install. Treating them as diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index c812862..d527ffc 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -389,7 +389,7 @@ def to_json(self) -> dict[str, Any]: return { "_type": "chat:Channel", "id": self._id, - "adapterName": self._adapter_name or self.adapter.name, + "adapterName": self._adapter_name if self._adapter_name else self.adapter.name, "channelVisibility": self._channel_visibility, "isDM": self._is_dm, } @@ -418,11 +418,18 @@ def from_json( """ if isinstance(data, ChannelImpl): return data + # Explicit None-checks (not `or`) to avoid the truthiness trap: + # `""` is a valid-but-falsy value that shouldn't silently fall + # through to the snake_case alias. See UPSTREAM_SYNC.md hazard #1. + raw_adapter_name = data["adapterName"] if "adapterName" in data else data.get("adapter_name") + raw_channel_visibility = ( + data["channelVisibility"] if "channelVisibility" in data else data.get("channel_visibility") + ) channel = cls( _ChannelImplConfigLazy( id=data["id"], - adapter_name=data.get("adapterName") or data.get("adapter_name", ""), - channel_visibility=data.get("channelVisibility") or data.get("channel_visibility", "unknown"), + adapter_name=raw_adapter_name if raw_adapter_name is not None else "", + channel_visibility=raw_channel_visibility if raw_channel_visibility is not None else "unknown", is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), ) ) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index b366b42..74392aa 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -680,12 +680,23 @@ async def _edit_loop() -> None: # "..." on the message forever. We replace it with " " so the # placeholder is cleared consistently with the no-placeholder branch # (which also posts " " in this case). See docs/UPSTREAM_SYNC.md. - await self.adapter.edit_message( - thread_id_for_edits, - msg.id, - PostableMarkdown(markdown=" "), - ) - last_edit_content = " " + # + # Some adapters (e.g. Telegram) reject whitespace-only content with + # a ValidationError. If the edit fails, we log and fall back to + # upstream's "leave placeholder visible" behavior for that adapter. + try: + await self.adapter.edit_message( + thread_id_for_edits, + msg.id, + PostableMarkdown(markdown=" "), + ) + last_edit_content = " " + except Exception as exc: + if self._logger: + self._logger.warn( + "fallbackStream placeholder-clear edit failed; placeholder will remain visible", + exc, + ) sent = self._create_sent_message( msg.id, @@ -733,7 +744,7 @@ def to_json(self) -> dict[str, Any]: "channelVisibility": self._channel_visibility, "currentMessage": self._current_message.to_json() if self._current_message else None, "isDM": self._is_dm, - "adapterName": self._adapter_name or self.adapter.name, + "adapterName": self._adapter_name if self._adapter_name else self.adapter.name, } @classmethod @@ -762,17 +773,26 @@ def from_json( """ if isinstance(data, ThreadImpl): return data - current_msg_raw = data.get("currentMessage") or data.get("current_message") + # Explicit None-checks (not `or`) to avoid the truthiness trap: + # `""` is a valid-but-falsy value that shouldn't silently fall + # through to the snake_case alias. See UPSTREAM_SYNC.md hazard #1. + raw_current = data["currentMessage"] if "currentMessage" in data else data.get("current_message") # ``object_hook`` revives nested dicts first, so ``currentMessage`` may # already be a Message instance by the time this runs. - current_msg = Message.from_json(current_msg_raw) if current_msg_raw else None + current_msg = Message.from_json(raw_current) if raw_current is not None else None + + raw_adapter_name = data["adapterName"] if "adapterName" in data else data.get("adapter_name") + raw_channel_id = data["channelId"] if "channelId" in data else data.get("channel_id") + raw_channel_visibility = ( + data["channelVisibility"] if "channelVisibility" in data else data.get("channel_visibility") + ) thread = cls( _ThreadImplConfig( id=data["id"], - adapter_name=data.get("adapterName") or data.get("adapter_name", ""), - channel_id=data.get("channelId") or data.get("channel_id", ""), - channel_visibility=data.get("channelVisibility") or data.get("channel_visibility", "unknown"), + adapter_name=raw_adapter_name if raw_adapter_name is not None else "", + channel_id=raw_channel_id if raw_channel_id is not None else "", + channel_visibility=raw_channel_visibility if raw_channel_visibility is not None else "unknown", current_message=current_msg, is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), ) diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 5d676c4..57b01e9 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -348,8 +348,10 @@ async def _stream(): # guard defers the first commit until stream end, so the final content # arrives via post rather than an intermediate edit. assert len(adapter._post_calls) >= 1 - last_post = adapter._post_calls[-1] - assert last_post[0] == "slack:C123:1234.5678" + last_thread_id, last_content = adapter._post_calls[-1] + assert last_thread_id == "slack:C123:1234.5678" + last_markdown = last_content.markdown if hasattr(last_content, "markdown") else last_content + assert last_markdown == "Hi" # ============================================================================ diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 631f16b..202705e 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -439,9 +439,9 @@ async def test_should_not_post_empty_content_when_table_is_buffered_with_null_pl text_stream = _create_text_stream(["| A | B |\n", "|---|---|\n", "| 1 | 2 |\n"]) await thread.post(text_stream) - for _, content in adapter._post_calls: - if isinstance(content, PostableMarkdown): - assert len(content.markdown.strip()) > 0 + markdown_posts = [content for _, content in adapter._post_calls if isinstance(content, PostableMarkdown)] + assert markdown_posts, "expected at least one PostableMarkdown post" + assert all(p.markdown.strip() for p in markdown_posts) # it("should not edit placeholder to empty during LLM warm-up") @pytest.mark.asyncio @@ -453,9 +453,9 @@ async def test_should_not_edit_placeholder_to_empty_during_llm_warmup(self): text_stream = _create_text_stream(["Hello world"]) await thread.post(text_stream) - for _, _, content in adapter._edit_calls: - if isinstance(content, PostableMarkdown): - assert len(content.markdown.strip()) > 0 + markdown_edits = [content for _, _, content in adapter._edit_calls if isinstance(content, PostableMarkdown)] + assert markdown_edits, "expected at least one PostableMarkdown edit" + assert all(e.markdown.strip() for e in markdown_edits) # it("should not post empty content during streaming with whitespace chunks") @pytest.mark.asyncio @@ -467,9 +467,9 @@ async def test_should_not_post_empty_content_during_streaming_with_whitespace_ch text_stream = _create_text_stream([" ", "\n", " \n"]) await thread.post(text_stream) - for _, content in adapter._post_calls: - if isinstance(content, PostableMarkdown): - assert len(content.markdown) > 0 + markdown_posts = [content for _, content in adapter._post_calls if isinstance(content, PostableMarkdown)] + assert markdown_posts, "expected at least one PostableMarkdown post" + assert all(len(p.markdown) > 0 for p in markdown_posts) # it("should preserve newlines in streamed text (native path)") @pytest.mark.asyncio From 8874aab16a4f12750a1dffa51a5da07832fb038d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:21:24 +0000 Subject: [PATCH 17/40] tighten to_json adapter_name + exercise warm-up path in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit follow-up on 8dd34d1: - to_json(): switch adapter_name fallback from truthy to explicit `is not None`. This matches upstream's `??` semantics (preserve stored "" instead of rewriting it) AND avoids triggering a lazy `self.adapter.name` resolution when `_adapter_name` was set to "" but no `_adapter` is bound — that combination would raise RuntimeError from the adapter property. - test_should_not_edit_placeholder_to_empty_during_llm_warmup: the original stream `["Hello world"]` never actually exercised the empty-edit guard path — by the time the edit loop fired the buffer already had real content. Now the stream yields a bare whitespace chunk, pauses long enough for the (10 ms) edit loop to fire, then yields the real content. A regression that emits empty intermediate edits would now fail here. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/thread.py | 5 ++++- tests/test_thread_faithful.py | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 74392aa..0ad1bf5 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -744,7 +744,10 @@ def to_json(self) -> dict[str, Any]: "channelVisibility": self._channel_visibility, "currentMessage": self._current_message.to_json() if self._current_message else None, "isDM": self._is_dm, - "adapterName": self._adapter_name if self._adapter_name else self.adapter.name, + # Explicit `is not None` matches upstream's `??` behavior (preserve + # "" if that's what was stored) and avoids triggering lazy adapter + # resolution when `_adapter_name` was set but no `_adapter` is bound. + "adapterName": self._adapter_name if self._adapter_name is not None else self.adapter.name, } @classmethod diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 202705e..7f77792 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -449,9 +449,18 @@ async def test_should_not_edit_placeholder_to_empty_during_llm_warmup(self): adapter = create_mock_adapter() state = create_mock_state() - thread = _make_thread(adapter, state) - text_stream = _create_text_stream(["Hello world"]) - await thread.post(text_stream) + # Simulate the warm-up path: a whitespace-only chunk arrives before the + # real content, and the background edit loop fires fast enough to see + # it. Without the warm-up chunk the test never exercises the empty-edit + # guard — it'd just post "Hello world" immediately. + thread = _make_thread(adapter, state, streaming_update_interval_ms=10) + + async def _stream() -> AsyncIterator[str]: + yield " " + await asyncio.sleep(0.05) + yield "Hello world\n" + + await thread.post(_stream()) markdown_edits = [content for _, _, content in adapter._edit_calls if isinstance(content, PostableMarkdown)] assert markdown_edits, "expected at least one PostableMarkdown edit" From 1ce9cbf9751c005393e317ee4a27f02b52048c68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:29:58 +0000 Subject: [PATCH 18/40] address remaining codex + coderabbit findings Three more findings from the latest review passes: - [Codex P2] gchat fallback now also triggers on `]` in the label, not just `|`/`>`/newline. Our to_ast() regex converts `` back to Markdown `[text](url)` form, which breaks if `text` contains `]` because the parser closes the link at the first `]`. Added `]` to the unsafe-char set alongside the others; updated the regression test to cover it and to exercise the emit path via a hand-built AST (from_markdown can't construct labels containing `]` because Markdown itself splits on it). - [CodeRabbit Major] ThreadImpl.from_json / ChannelImpl.from_json: the `isinstance(data, ThreadImpl): return data` / `isinstance(data, ChannelImpl): return data` shortcut bypassed the adapter/chat rebinding block, so `from_json(thread_instance, adapter=X)` silently left _adapter stale. Changed both to reuse the existing instance but still flow into the rebinding logic. Added a regression test that rebinds an already-revived ThreadImpl to a different adapter and asserts to_json reflects the new name. - [CodeRabbit nit] tightened the whitespace-chunks-no-placeholder test to assert `markdown == " "` exactly (not just non-empty), so a regression that posts the raw buffer `" \n"` would fail. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 14 +++-- src/chat_sdk/channel.py | 37 ++++++------ src/chat_sdk/thread.py | 53 +++++++++-------- tests/test_gchat_format_extended.py | 58 +++++++++++++++---- tests/test_serialization.py | 25 ++++++++ tests/test_thread_faithful.py | 8 ++- 6 files changed, 134 insertions(+), 61 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 43e14b2..54a72ea 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -118,12 +118,14 @@ def _node_to_gchat(self, node: Content) -> str: if link_text == url: return url # Divergence from upstream — see docs/UPSTREAM_SYNC.md. - # Labels containing the `|` or `>` delimiters, or a newline, can't - # be emitted safely in form — Google Chat (and our own - # round-trip regex) stops at the first `>` / `|`. Fall back to - # plain `text (url)` so the label text is preserved and Google - # Chat's auto-link detection still makes the URL clickable. - if "|" in link_text or ">" in link_text or "\n" in link_text: + # Labels containing `|`, `>`, `]`, or a newline can't be emitted + # safely in form: Google Chat (and our own round-trip + # regex) stops at the first `>` / `|`, and `]` prematurely closes + # the label when to_ast() converts `` to Markdown + # `[text](url)` form. Fall back to plain `text (url)` so the label + # text is preserved and Google Chat's auto-link detection still + # makes the URL clickable. + if any(c in link_text for c in ("|", ">", "]", "\n")): return f"{link_text} ({url})" return f"<{url}|{link_text}>" diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index d527ffc..2420cab 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -413,26 +413,29 @@ def from_json( Explicit Chat instance for adapter/state resolution. Idempotent: if ``data`` is already a :class:`ChannelImpl`, it is - returned unchanged. This makes it safe to call via - ``json.loads(..., object_hook=reviver)``. + reused (not reconstructed) but any ``adapter``/``chat`` arguments + are still applied. This makes it safe to call via + ``json.loads(..., object_hook=reviver)`` while still respecting + explicit rebinding. """ if isinstance(data, ChannelImpl): - return data - # Explicit None-checks (not `or`) to avoid the truthiness trap: - # `""` is a valid-but-falsy value that shouldn't silently fall - # through to the snake_case alias. See UPSTREAM_SYNC.md hazard #1. - raw_adapter_name = data["adapterName"] if "adapterName" in data else data.get("adapter_name") - raw_channel_visibility = ( - data["channelVisibility"] if "channelVisibility" in data else data.get("channel_visibility") - ) - channel = cls( - _ChannelImplConfigLazy( - id=data["id"], - adapter_name=raw_adapter_name if raw_adapter_name is not None else "", - channel_visibility=raw_channel_visibility if raw_channel_visibility is not None else "unknown", - is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), + channel = data + else: + # Explicit None-checks (not `or`) to avoid the truthiness trap: + # `""` is a valid-but-falsy value that shouldn't silently fall + # through to the snake_case alias. See UPSTREAM_SYNC.md hazard #1. + raw_adapter_name = data["adapterName"] if "adapterName" in data else data.get("adapter_name") + raw_channel_visibility = ( + data["channelVisibility"] if "channelVisibility" in data else data.get("channel_visibility") + ) + channel = cls( + _ChannelImplConfigLazy( + id=data["id"], + adapter_name=raw_adapter_name if raw_adapter_name is not None else "", + channel_visibility=raw_channel_visibility if raw_channel_visibility is not None else "unknown", + is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), + ) ) - ) if adapter is not None: channel._adapter = adapter # Divergence from upstream — see docs/UPSTREAM_SYNC.md. diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 0ad1bf5..71b337b 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -771,35 +771,38 @@ def from_json( or test scenarios. Idempotent: if ``data`` is already a :class:`ThreadImpl`, it is - returned unchanged. This makes it safe to call via - ``json.loads(..., object_hook=reviver)``. + reused (not reconstructed) but any ``adapter``/``chat`` arguments + are still applied. This makes it safe to call via + ``json.loads(..., object_hook=reviver)`` while still respecting + explicit rebinding. """ if isinstance(data, ThreadImpl): - return data - # Explicit None-checks (not `or`) to avoid the truthiness trap: - # `""` is a valid-but-falsy value that shouldn't silently fall - # through to the snake_case alias. See UPSTREAM_SYNC.md hazard #1. - raw_current = data["currentMessage"] if "currentMessage" in data else data.get("current_message") - # ``object_hook`` revives nested dicts first, so ``currentMessage`` may - # already be a Message instance by the time this runs. - current_msg = Message.from_json(raw_current) if raw_current is not None else None - - raw_adapter_name = data["adapterName"] if "adapterName" in data else data.get("adapter_name") - raw_channel_id = data["channelId"] if "channelId" in data else data.get("channel_id") - raw_channel_visibility = ( - data["channelVisibility"] if "channelVisibility" in data else data.get("channel_visibility") - ) + thread = data + else: + # Explicit None-checks (not `or`) to avoid the truthiness trap: + # `""` is a valid-but-falsy value that shouldn't silently fall + # through to the snake_case alias. See UPSTREAM_SYNC.md hazard #1. + raw_current = data["currentMessage"] if "currentMessage" in data else data.get("current_message") + # ``object_hook`` revives nested dicts first, so ``currentMessage`` + # may already be a Message instance by the time this runs. + current_msg = Message.from_json(raw_current) if raw_current is not None else None + + raw_adapter_name = data["adapterName"] if "adapterName" in data else data.get("adapter_name") + raw_channel_id = data["channelId"] if "channelId" in data else data.get("channel_id") + raw_channel_visibility = ( + data["channelVisibility"] if "channelVisibility" in data else data.get("channel_visibility") + ) - thread = cls( - _ThreadImplConfig( - id=data["id"], - adapter_name=raw_adapter_name if raw_adapter_name is not None else "", - channel_id=raw_channel_id if raw_channel_id is not None else "", - channel_visibility=raw_channel_visibility if raw_channel_visibility is not None else "unknown", - current_message=current_msg, - is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), + thread = cls( + _ThreadImplConfig( + id=data["id"], + adapter_name=raw_adapter_name if raw_adapter_name is not None else "", + channel_id=raw_channel_id if raw_channel_id is not None else "", + channel_visibility=raw_channel_visibility if raw_channel_visibility is not None else "unknown", + current_message=current_msg, + is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), + ) ) - ) if adapter is not None: thread._adapter = adapter # Divergence from upstream — see docs/UPSTREAM_SYNC.md. diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 4a387a2..38f13cb 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -168,17 +168,53 @@ def test_should_preserve_custom_link_labels_in_posted_messages(self): assert result == "" def test_falls_back_to_parenthesized_form_when_label_contains_reserved_chars(self): - """Labels containing `>` / `|` / newline would produce malformed - `` output (Google Chat + our own regex stop at the first - `>` or `|`), so fall back to plain `text (url)` so the label is - preserved intact and the URL is still auto-detected as a link.""" - converter = _converter() - for label in ["a > b", "a | b", "a\nb"]: - result = converter.from_markdown(f"[{label}](https://example.com)") - assert result == f"{label} (https://example.com)" - # Sanity: the safe-labels path still uses . - safe = converter.from_markdown("[ok](https://example.com)") - assert safe == "" + """Labels containing `>` / `|` / `]` / newline would produce malformed + `` output: Google Chat and our own regex stop at the first + `>` or `|`, and `]` prematurely closes the Markdown link when to_ast() + converts the `` form back to `[text](url)`. Fall back to + plain `text (url)` so the label is preserved intact and the URL is + still auto-detected as a link. + + Note: `from_markdown` can't construct these labels because the Markdown + parser itself splits on `]`/newline. We exercise the `from_ast` emit + path directly with a hand-built AST instead. + """ + converter = _converter() + for label in ["a > b", "a | b", "a ] b", "a\nb"]: + ast = { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "url": "https://example.com", + "children": [{"type": "text", "value": label}], + } + ], + } + ], + } + result = converter.from_ast(ast) + assert result == f"{label} (https://example.com)", f"label={label!r}" + # Sanity: labels without reserved chars still use the form. + ok_ast = { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "url": "https://example.com", + "children": [{"type": "text", "value": "ok"}], + } + ], + } + ], + } + assert converter.from_ast(ok_ast) == "" def test_blockquote(self): converter = _converter() diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 6b35968..cf2552b 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -579,6 +579,31 @@ def test_should_reconstruct_thread_from_json(self, mock_adapter, mock_state): assert thread.is_dm is False assert thread.adapter.name == "slack" + def test_should_rebind_adapter_when_data_is_already_a_threadimpl(self, mock_state): + """Idempotent path: when ``data`` is already a ThreadImpl (e.g. revived + via ``object_hook``), passing an explicit ``adapter=`` must still rebind + it — an early-return shortcut would leave ``_adapter`` stale. Regression + for a CodeRabbit finding on commit 8dd34d1.""" + from chat_sdk.testing import create_mock_adapter + + first = create_mock_adapter("slack") + second = create_mock_adapter("teams") + original = ThreadImpl.from_json( + { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channel_id": "C123", + "is_dm": False, + "adapter_name": "slack", + }, + adapter=first, + ) + rebound = ThreadImpl.from_json(original, adapter=second) + + # Rebind applied even though data was already a ThreadImpl: + assert rebound.adapter.name == "teams" + assert rebound.to_json()["adapterName"] == "teams" + def test_should_sync_adapter_name_when_explicit_adapter_is_bound(self, mock_state): """from_json(data, adapter=X) must update _adapter_name to X.name so to_json() doesn't serialize a stale name that refers to a different diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 7f77792..091a5ae 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -476,9 +476,13 @@ async def test_should_not_post_empty_content_during_streaming_with_whitespace_ch text_stream = _create_text_stream([" ", "\n", " \n"]) await thread.post(text_stream) + # Whitespace-only stream with placeholder disabled: the SDK normalizes + # to a single `" "` in the final post_message call, not the original + # whitespace buffer. Asserting the exact value catches regressions that + # would silently emit " \n" or similar. markdown_posts = [content for _, content in adapter._post_calls if isinstance(content, PostableMarkdown)] - assert markdown_posts, "expected at least one PostableMarkdown post" - assert all(len(p.markdown) > 0 for p in markdown_posts) + assert len(markdown_posts) == 1 + assert markdown_posts[0].markdown == " " # it("should preserve newlines in streamed text (native path)") @pytest.mark.asyncio From 5926e427c99f0da1c3842be93aea6f348588c0c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 03:30:24 +0000 Subject: [PATCH 19/40] docs: add `]` to the gchat link-label non-parity row 1ce9cbf extended the reserved-char fallback to include `]`; keep the UPSTREAM_SYNC.md table accurate for the next sync. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/UPSTREAM_SYNC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index a5aee45..090ddb7 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -446,7 +446,7 @@ stay explicit instead of being rediscovered in code review. | Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | | Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text with no link node. We close the round-trip. | | `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | -| Google Chat link labels with `\|` / `>` / newline | Fall back to `text (url)` when the label contains reserved delimiters | Always ``, producing malformed output | Google Chat's `` has no escape for `\|` or `>`; emitting either character truncates the label on the platform and breaks our own round-trip regexes. Upstream emits the malformed form; we fall back to the pre-4.26 `text (url)` form in that narrow case so the label stays intact and the URL still auto-links. | +| Google Chat link labels with `\|` / `>` / `]` / newline | Fall back to `text (url)` when the label contains reserved delimiters | Always ``, producing malformed output | Google Chat's `` has no escape for `\|` or `>`; `]` breaks our own `to_ast()` regex (which converts `` to Markdown `[text](url)`, and Markdown closes the label at the first `]`); newline breaks the single-line form. Upstream emits the malformed form regardless; we fall back to the pre-4.26 `text (url)` form in those narrow cases so the label stays intact and the URL still auto-links. | ### Platform-specific gaps From 891ef608f8d75385e8fc757479d030cac50e7fae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 21:59:40 +0000 Subject: [PATCH 20/40] fix(types): rename format converter params to platform_text + pin pyrefly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pyrefly 0.61.1 tightened bad-override-param-name detection and surfaced 7 real `to_ast` / `extract_plain_text` overrides whose parameter names diverged from the BaseFormatConverter signature (`platform_text`): - github/format_converter.py `markdown` -> `platform_text` - google_chat/format_converter.py `gchat_text` -> `platform_text` - google_chat/format_converter.py `text` -> `platform_text` - slack/format_converter.py `mrkdwn` -> `platform_text` (x2) - telegram/format_converter.py `text` -> `platform_text` - whatsapp/format_converter.py `markdown` -> `platform_text` Some subagents during the type-cleanup sweep renamed these; others didn't, which is why 0.61.0 passed locally (baseline captured the old errors at that version) but 0.61.1 in CI detected them as new. Also pin pyrefly to `==0.61.1` — any future patch bump that tightens detection further would flake CI against the frozen baseline. We can move the pin forward deliberately when refreshing the baseline. No runtime behavior change (parameter name is internal). https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- pyproject.toml | 2 +- src/chat_sdk/adapters/github/format_converter.py | 4 ++-- src/chat_sdk/adapters/google_chat/format_converter.py | 8 ++++---- src/chat_sdk/adapters/slack/format_converter.py | 8 ++++---- src/chat_sdk/adapters/telegram/format_converter.py | 4 ++-- src/chat_sdk/adapters/whatsapp/format_converter.py | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dff361b..c563cf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dev = [ "aiohttp>=3.9", "pyjwt[crypto]>=2.8", "ruff>=0.4.0", - "pyrefly>=0.61", + "pyrefly==0.61.1", ] # --------------------------------------------------------------------------- diff --git a/src/chat_sdk/adapters/github/format_converter.py b/src/chat_sdk/adapters/github/format_converter.py index 67a2724..0bcf4f1 100644 --- a/src/chat_sdk/adapters/github/format_converter.py +++ b/src/chat_sdk/adapters/github/format_converter.py @@ -34,9 +34,9 @@ def from_ast(self, ast: Root) -> str: return "" return stringify_markdown(ast).strip() - def to_ast(self, markdown: str) -> Root: + def to_ast(self, platform_text: str) -> Root: """Parse GitHub markdown into an AST. GitHub uses standard GFM, so we use the shared parser directly. """ - return parse_markdown(markdown) + return parse_markdown(platform_text) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 54a72ea..f490b33 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -34,13 +34,13 @@ def from_ast(self, ast: Root) -> str: """Render an AST to Google Chat format.""" return self._from_ast_with_node_converter(ast, self._node_to_gchat) - def to_ast(self, gchat_text: str) -> Root: + def to_ast(self, platform_text: str) -> Root: """Parse Google Chat message into an AST. Converts Google Chat format to standard markdown, then parses with the shared parser. """ - markdown = gchat_text + markdown = platform_text # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Google Chat custom link syntax -> [text](url). Must run @@ -58,13 +58,13 @@ def to_ast(self, gchat_text: str) -> Root: # Italic and code are the same format as markdown return parse_markdown(markdown) - def extract_plain_text(self, text: str) -> str: + def extract_plain_text(self, platform_text: str) -> str: """Extract plain text from Google Chat formatted text. Strips formatting markers while preserving the text content. """ # Remove code blocks - result = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), text) + result = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), platform_text) # Inline code result = re.sub(r"`([^`]+)`", r"\1", result) # Divergence from upstream — see docs/UPSTREAM_SYNC.md. diff --git a/src/chat_sdk/adapters/slack/format_converter.py b/src/chat_sdk/adapters/slack/format_converter.py index 6e8e5b6..0217381 100644 --- a/src/chat_sdk/adapters/slack/format_converter.py +++ b/src/chat_sdk/adapters/slack/format_converter.py @@ -37,13 +37,13 @@ def from_ast(self, ast: Root) -> str: """Render an AST to Slack mrkdwn format.""" return self._from_ast_with_node_converter(ast, self._node_to_mrkdwn) - def to_ast(self, mrkdwn: str) -> Root: + def to_ast(self, platform_text: str) -> Root: """Parse Slack mrkdwn into an AST. Converts Slack-specific syntax to standard markdown, then parses with the shared parser. """ - markdown = mrkdwn + markdown = platform_text # User mentions: <@U123|name> -> @name or <@U123> -> @U123 markdown = re.sub(r"<@([A-Z0-9_]+)\|([^<>]+)>", r"@\2", markdown) @@ -94,9 +94,9 @@ def render_postable(self, message: Any) -> str: return self.from_ast(message.ast) return "" - def extract_plain_text(self, mrkdwn: str) -> str: + def extract_plain_text(self, platform_text: str) -> str: """Extract plain text from Slack mrkdwn by stripping formatting.""" - text = mrkdwn + text = platform_text # Remove user mentions formatting: <@U123|name> -> @name, <@U123> -> @U123 text = re.sub(r"<@([A-Z0-9_]+)\|([^<>]+)>", r"@\2", text) diff --git a/src/chat_sdk/adapters/telegram/format_converter.py b/src/chat_sdk/adapters/telegram/format_converter.py index 58d333e..24446b8 100644 --- a/src/chat_sdk/adapters/telegram/format_converter.py +++ b/src/chat_sdk/adapters/telegram/format_converter.py @@ -50,6 +50,6 @@ def visitor(node: Content) -> Content: transformed = walk_ast(copy.deepcopy(ast), visitor) return stringify_markdown(transformed).strip() - def to_ast(self, text: str) -> Root: + def to_ast(self, platform_text: str) -> Root: """Parse plain text / markdown into an AST using the shared parser.""" - return parse_markdown(text) + return parse_markdown(platform_text) diff --git a/src/chat_sdk/adapters/whatsapp/format_converter.py b/src/chat_sdk/adapters/whatsapp/format_converter.py index 79c827b..8920419 100644 --- a/src/chat_sdk/adapters/whatsapp/format_converter.py +++ b/src/chat_sdk/adapters/whatsapp/format_converter.py @@ -79,13 +79,13 @@ def visitor(node: Content) -> Content: return self._to_whatsapp_format(markdown) - def to_ast(self, markdown: str) -> Root: + def to_ast(self, platform_text: str) -> Root: """Parse WhatsApp markdown into an AST. Transforms WhatsApp-specific formatting to standard markdown first, then parses with the shared parser. """ - standard_markdown = self._from_whatsapp_format(markdown) + standard_markdown = self._from_whatsapp_format(platform_text) return parse_markdown(standard_markdown) def _to_whatsapp_format(self, text: str) -> str: From a501fffa0ff5edefd7425ca57c3d5be6d6ffa798 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 22:04:21 +0000 Subject: [PATCH 21/40] fix(channel): use `is not None` for adapter_name serialization + document URL paren limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the latest Codex review: - [P2] ChannelImpl.to_json: the same `or` truthiness trap I fixed in ThreadImpl.to_json in 8874aab was still present in channel.py. `self._adapter_name if self._adapter_name else self.adapter.name` triggers lazy adapter resolution when _adapter_name is "", which breaks the workflow-safe reserialization path added in earlier commits. Aligned with thread.py using explicit `is not None`. - [P1 → Known limitation] Google Chat `` decoding corrupts URLs containing unescaped `)` (e.g. Wikipedia-style links). A proper fix requires AST-level placeholder substitution because our Markdown parser doesn't implement CommonMark's balanced-parens rule for link destinations. Documented as a known limitation on the non-parity row and at the regex site in format_converter.py. Upstream doesn't round-trip at all, so our behavior is still strictly better for the common case (URLs without parens). https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/UPSTREAM_SYNC.md | 2 +- src/chat_sdk/adapters/google_chat/format_converter.py | 5 +++++ src/chat_sdk/channel.py | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 090ddb7..9d6cfcf 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -444,7 +444,7 @@ stay explicit instead of being rediscovered in code review. | PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history | | Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers | | Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | -| Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text with no link node. We close the round-trip. | +| Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text with no link node. We close the round-trip via a regex that converts `` → `[text](url)` before `parse_markdown`. **Known limitation**: URLs containing unescaped `)` (e.g. Wikipedia-style `https://en.wikipedia.org/wiki/Foo_(bar)`) are truncated at the first `)` because our Markdown parser doesn't implement CommonMark's balanced-parens rule for link destinations. A proper fix requires AST-level placeholder substitution, which is out of scope for this sync; upstream has no such round-trip at all so it's still strictly better than upstream's "raw text" behavior for the common case. | | `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | | Google Chat link labels with `\|` / `>` / `]` / newline | Fall back to `text (url)` when the label contains reserved delimiters | Always ``, producing malformed output | Google Chat's `` has no escape for `\|` or `>`; `]` breaks our own `to_ast()` regex (which converts `` to Markdown `[text](url)`, and Markdown closes the label at the first `]`); newline breaks the single-line form. Upstream emits the malformed form regardless; we fall back to the pre-4.26 `text (url)` form in those narrow cases so the label stays intact and the URL still auto-links. | diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index f490b33..296f450 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -47,6 +47,11 @@ def to_ast(self, platform_text: str) -> Root: # before bold/strikethrough so the `|` inside a link label isn't # matched by those patterns. Accepts any RFC 3986 scheme, not just # http(s), so mailto:/tel:/etc. round-trip cleanly. + # + # Known limitation: URLs containing unescaped `)` are truncated at + # the first `)` because our Markdown parser doesn't implement + # CommonMark's balanced-parens rule. Upstream has no round-trip + # at all, so this is still strictly better for the common case. markdown = re.sub(r"<([a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+)\|([^>]+)>", r"[\2](\1)", markdown) # Bold: *text* -> **text** diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 2420cab..dcfca56 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -389,7 +389,10 @@ def to_json(self) -> dict[str, Any]: return { "_type": "chat:Channel", "id": self._id, - "adapterName": self._adapter_name if self._adapter_name else self.adapter.name, + # Explicit `is not None` matches upstream's `??` behavior (preserve + # "" if that's what was stored) and avoids triggering lazy adapter + # resolution when `_adapter_name` was set but no `_adapter` is bound. + "adapterName": self._adapter_name if self._adapter_name is not None else self.adapter.name, "channelVisibility": self._channel_visibility, "isDM": self._is_dm, } From 4da149fed6e81ea20af21048845baefc8f8099ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 22:05:48 +0000 Subject: [PATCH 22/40] tighten two more CodeRabbit tests; hold one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two of three latest CodeRabbit findings landed; one rejected as incorrect. Landed: - tests/test_serialization.py: the channel re-serialize-without- singleton test now routes the payload through `json.loads(..., object_hook=reviver)` with an `isinstance(channel, ChannelImpl)` assertion. Before, only `ChannelImpl.from_json()` was exercised, so a regression in `chat_sdk.reviver`'s `chat:Channel` dispatch would not fail the test. - tests/test_thread_faithful.py (no-placeholder "Hi"): strengthened to assert the exact call sequence — exactly one post_message("Hi") and zero edits. Previously only the last post was pinned, so a regression splitting the flow into an empty post + later edit would still pass. Rejected: - tests/test_thread_faithful.py "should_log_when_an_intermediate_edit_ fails": CodeRabbit claimed the code logs "fallbackStream _edit_loop failed" from the background path, but the actual string at src/chat_sdk/thread.py:635 is "fallbackStream edit failed". Test matches code and passes; no change. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- tests/test_serialization.py | 6 +++++- tests/test_thread_faithful.py | 12 ++++++++---- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index cf2552b..29968c6 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -1109,7 +1109,11 @@ def test_should_allow_reserialization_of_a_revived_channel_without_singleton(sel "isDM": False, "adapterName": "slack", } - channel = ChannelImpl.from_json(data) + # Route through the public `chat_sdk.reviver` entry point rather than + # `ChannelImpl.from_json` directly so a regression in the reviver's + # "chat:Channel" dispatch would fail here too. + channel = json.loads(json.dumps(data), object_hook=reviver) + assert isinstance(channel, ChannelImpl) reserialized = channel.to_json() assert reserialized["_type"] == "chat:Channel" assert reserialized["adapterName"] == "slack" diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 091a5ae..102acfe 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -388,10 +388,14 @@ async def test_should_support_disabling_the_placeholder_for_fallback_streaming(s assert len(placeholder_calls) == 0 # Final content is delivered through post since no mid-stream commit # fired (no newline in the chunks) and the empty-content guard - # prevents an intermediate empty post. - last_post = adapter._post_calls[-1] - assert isinstance(last_post[1], PostableMarkdown) - assert last_post[1].markdown == "Hi" + # prevents an intermediate empty post. The whole exchange is + # exactly one post_message("Hi") and zero edits — any regression + # that splits it into an early post + late edit would fail here. + assert len(adapter._post_calls) == 1 + assert len(adapter._edit_calls) == 0 + only_post = adapter._post_calls[0] + assert isinstance(only_post[1], PostableMarkdown) + assert only_post[1].markdown == "Hi" # Python-specific regression: ensure whitespace-only streams don't leave # the placeholder stuck on the message. This is a deliberate divergence From e08b85e923a2d0c99ce25834f69502251aefce73 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 23:09:32 +0000 Subject: [PATCH 23/40] fix(gchat): AST placeholder substitution for round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The regex-based `` → `[text](url)` conversion in to_ast couldn't handle URLs containing `)` (e.g. Wikipedia-style `https://en.wikipedia.org/wiki/Foo_(bar)`) because our Markdown parser doesn't implement CommonMark's balanced-parens rule for link destinations. Codex flagged this as P1 and my last round deferred it as a "known limitation". Compact correct fix: bypass Markdown for the custom-link tokens. 1. Extract each `` match to a private-use placeholder `\ue000LINK{idx}\ue000` and remember `(url, text)`. 2. Run the usual bold / strikethrough conversions + parse_markdown on the placeholder-bearing text. 3. Walk the resulting AST, find text nodes containing placeholders, and inject real link nodes in their place. PUA code points (U+E000) are guaranteed to not appear in user-authored text, so sentinel collisions aren't a concern. - Wiki-style URLs with parens now round-trip intact - Multiple tokens in a single paragraph split cleanly - mailto: / tel: / other schemes still work (regression test intact) - Removed the "known limitation" row from UPSTREAM_SYNC.md; this path is now strictly better than upstream, which either leaves as raw text or parses the whole thing as a malformed autolink URL. No change to extract_plain_text — it was already correct (its regex stops at `|`/`>`, not `)`). https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/UPSTREAM_SYNC.md | 2 +- .../adapters/google_chat/format_converter.py | 77 ++++++++++++++++--- tests/test_gchat_format_extended.py | 21 +++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 9d6cfcf..05d818a 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -444,7 +444,7 @@ stay explicit instead of being rediscovered in code review. | PostableObject history | Cached in message history with real message ID | Not cached (skips history) | Upstream gap — posted messages should appear in thread/channel history | | Teams `msteams` transport key | Stripped from action values | Not stripped | Upstream gap — SDK-injected metadata should not leak to handlers | | Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | -| Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text with no link node. We close the round-trip via a regex that converts `` → `[text](url)` before `parse_markdown`. **Known limitation**: URLs containing unescaped `)` (e.g. Wikipedia-style `https://en.wikipedia.org/wiki/Foo_(bar)`) are truncated at the first `)` because our Markdown parser doesn't implement CommonMark's balanced-parens rule for link destinations. A proper fix requires AST-level placeholder substitution, which is out of scope for this sync; upstream has no such round-trip at all so it's still strictly better than upstream's "raw text" behavior for the common case. | +| Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text (or parse the whole string as an autolink with a malformed URL) | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text (or worse, a link node with the full `url\|text` as its URL) in upstream. We close the round-trip via an AST placeholder substitution: each `` is extracted to a private-use sentinel, Markdown is parsed on the rest, and link nodes are injected where the sentinels landed. This avoids the Markdown parser's incomplete handling of balanced-parens link destinations, so URLs like `https://en.wikipedia.org/wiki/Foo_(bar)` round-trip intact. | | `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | | Google Chat link labels with `\|` / `>` / `]` / newline | Fall back to `text (url)` when the label contains reserved delimiters | Always ``, producing malformed output | Google Chat's `` has no escape for `\|` or `>`; `]` breaks our own `to_ast()` regex (which converts `` to Markdown `[text](url)`, and Markdown closes the label at the first `]`); newline breaks the single-line form. Upstream emits the malformed form regardless; we fall back to the pre-4.26 `text (url)` form in those narrow cases so the label stays intact and the URL still auto-links. | diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 296f450..88b57d2 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -23,6 +23,52 @@ table_to_ascii, ) +_GCHAT_LINK_RE = re.compile(r"<([a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+)\|([^>]+)>") +# Private-use code point chosen as a placeholder token. PUA characters are +# guaranteed never to appear in user-authored text, so the round-trip +# substitution (emit placeholder → parse Markdown → inject link nodes) can +# use them as unique markers without collision risk. +_GCHAT_LINK_PLACEHOLDER = "\ue000LINK{idx}\ue000" +_GCHAT_LINK_PLACEHOLDER_RE = re.compile(r"\ue000LINK(\d+)\ue000") + + +def _inject_link_placeholders(node: dict, links: list[tuple[str, str]]) -> None: + """Walk the AST in place and expand ``\\ue000LINK{idx}\\ue000`` placeholders + in text nodes into real link nodes. Used by ``to_ast`` to bypass the + Markdown parser for custom ```` tokens whose URLs may contain + characters the parser can't round-trip (e.g. unescaped ``)``). + """ + children = node.get("children") + if not isinstance(children, list): + return + new_children: list = [] + for child in children: + if isinstance(child, dict) and child.get("type") == "text": + value = child.get("value", "") + if "\ue000" not in value: + new_children.append(child) + continue + parts = _GCHAT_LINK_PLACEHOLDER_RE.split(value) + # parts alternates plain-text / placeholder-index / plain-text / ... + for i, part in enumerate(parts): + if i % 2 == 0: + if part: + new_children.append({"type": "text", "value": part}) + else: + url, text = links[int(part)] + new_children.append( + { + "type": "link", + "url": url, + "children": [{"type": "text", "value": text}], + } + ) + else: + if isinstance(child, dict): + _inject_link_placeholders(child, links) + new_children.append(child) + node["children"] = new_children + class GoogleChatFormatConverter(BaseFormatConverter): """Format converter between standard markdown AST and Google Chat format. @@ -43,16 +89,22 @@ def to_ast(self, platform_text: str) -> Root: markdown = platform_text # Divergence from upstream — see docs/UPSTREAM_SYNC.md. - # Google Chat custom link syntax -> [text](url). Must run - # before bold/strikethrough so the `|` inside a link label isn't - # matched by those patterns. Accepts any RFC 3986 scheme, not just - # http(s), so mailto:/tel:/etc. round-trip cleanly. - # - # Known limitation: URLs containing unescaped `)` are truncated at - # the first `)` because our Markdown parser doesn't implement - # CommonMark's balanced-parens rule. Upstream has no round-trip - # at all, so this is still strictly better for the common case. - markdown = re.sub(r"<([a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+)\|([^>]+)>", r"[\2](\1)", markdown) + # Google Chat custom link syntax `` has to survive our + # Markdown parser, which doesn't implement CommonMark's balanced- + # parens rule for link destinations. A naive regex substitution to + # `[text](url)` would corrupt URLs containing `)` (e.g. Wikipedia- + # style `https://en.wikipedia.org/wiki/Foo_(bar)`). Instead we + # extract each match to a PUA placeholder, parse the rest as + # Markdown, and inject real link nodes where the placeholders + # land. Accepts any RFC 3986 scheme. + links: list[tuple[str, str]] = [] + + def _capture(match: re.Match[str]) -> str: + idx = len(links) + links.append((match.group(1), match.group(2))) + return _GCHAT_LINK_PLACEHOLDER.format(idx=idx) + + markdown = _GCHAT_LINK_RE.sub(_capture, markdown) # Bold: *text* -> **text** markdown = re.sub(r"(? Root: markdown = re.sub(r"(? str: """Extract plain text from Google Chat formatted text. diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 38f13cb..7fc6607 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -79,6 +79,27 @@ def _walk(node: object) -> None: _walk(ast) assert found, "Expected a link node with url='https://example.com' and text='Example'" + def test_gchat_custom_link_parses_url_with_balanced_parens(self): + """URLs containing `(...)` (e.g. Wikipedia-style) must round-trip + intact. The Markdown parser doesn't implement CommonMark's balanced- + parens rule for link destinations, so a naive regex rewrite + `` → `[text](url)` would truncate the URL at the first `)`. + The AST placeholder substitution path in `to_ast` bypasses the parser + for these tokens and injects a link node with the full URL intact.""" + converter = _converter() + ast = converter.to_ast("See for info") + link_urls: list[str] = [] + + def _walk(node: object) -> None: + if isinstance(node, dict): + if node.get("type") == "link": + link_urls.append(node.get("url", "")) + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + assert link_urls == ["https://example.com/a_(b)"], f"expected intact URL, got {link_urls!r}" + def test_gchat_custom_link_parses_non_http_schemes(self): """mailto:/tel:/etc. emit in from_ast; to_ast must accept any RFC 3986 scheme, not just http(s).""" From d62e0e668444a31a4e884cea14c365828996e3c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Apr 2026 23:24:44 +0000 Subject: [PATCH 24/40] fix(gchat): preserve inside code spans, guard out-of-range placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex findings on the AST-placeholder fix in e08b85e: - [P1] `` inside inline / fenced code was also extracted to placeholders during the pre-parse pass, but `_inject_link_placeholders` only visited `text` nodes. Code spans ended up with raw `\ue000LINK0\ue000` in their `value`, silently corrupting snippets that legitimately contain Google Chat link syntax (e.g. a code example). Now the walker also visits `inlineCode` and `code` nodes and rewrites any placeholders back to their original `` literal — code spans stay code, not links. - [P2] `links[int(part)]` had no bounds check, so crafted or accidental user input containing a stray `\ue000LINK999\ue000` token would raise `IndexError` and fail message parsing entirely. Now an unresolvable index is preserved as literal text — same defensive posture as the code-span case. Added two regression tests: - test_gchat_custom_link_syntax_inside_code_span_stays_literal covers both inline and fenced code. - test_gchat_custom_link_tolerates_out_of_range_placeholder crafts a bogus placeholder alongside a real and asserts the real one still parses (and we don't raise). https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 56 +++++++++++++++---- tests/test_gchat_format_extended.py | 48 ++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 88b57d2..5077a56 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -37,13 +37,35 @@ def _inject_link_placeholders(node: dict, links: list[tuple[str, str]]) -> None: in text nodes into real link nodes. Used by ``to_ast`` to bypass the Markdown parser for custom ```` tokens whose URLs may contain characters the parser can't round-trip (e.g. unescaped ``)``). + + Code spans (``inlineCode`` / ``code``) are handled specially: their content + is literal, so placeholders in them are rewritten back to the original + ```` syntax rather than being turned into link nodes. Out-of- + range placeholder indices (which can appear if crafted/accidental user + input happens to match the placeholder pattern) are left as literal text + rather than raising ``IndexError``. """ + + def _placeholder_to_literal(value: str) -> str: + def _sub(match: re.Match[str]) -> str: + i = int(match.group(1)) + if 0 <= i < len(links): + url, text = links[i] + return f"<{url}|{text}>" + return match.group(0) + + return _GCHAT_LINK_PLACEHOLDER_RE.sub(_sub, value) + children = node.get("children") if not isinstance(children, list): return new_children: list = [] for child in children: - if isinstance(child, dict) and child.get("type") == "text": + if not isinstance(child, dict): + new_children.append(child) + continue + ctype = child.get("type") + if ctype == "text": value = child.get("value", "") if "\ue000" not in value: new_children.append(child) @@ -55,17 +77,29 @@ def _inject_link_placeholders(node: dict, links: list[tuple[str, str]]) -> None: if part: new_children.append({"type": "text", "value": part}) else: - url, text = links[int(part)] - new_children.append( - { - "type": "link", - "url": url, - "children": [{"type": "text", "value": text}], - } - ) + idx = int(part) + if 0 <= idx < len(links): + url, text = links[idx] + new_children.append( + { + "type": "link", + "url": url, + "children": [{"type": "text", "value": text}], + } + ) + else: + # Unknown placeholder (malformed / crafted input). + # Preserve as literal text rather than raising. + new_children.append({"type": "text", "value": f"\ue000LINK{idx}\ue000"}) + elif ctype in ("inlineCode", "code") and isinstance(child.get("value"), str): + # Code spans are literal — a `` inside them is user + # content, not a link. Restore the original syntax rather than + # leaving the placeholder embedded in the code value. + if "\ue000" in child["value"]: + child["value"] = _placeholder_to_literal(child["value"]) + new_children.append(child) else: - if isinstance(child, dict): - _inject_link_placeholders(child, links) + _inject_link_placeholders(child, links) new_children.append(child) node["children"] = new_children diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 7fc6607..288f565 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -79,6 +79,54 @@ def _walk(node: object) -> None: _walk(ast) assert found, "Expected a link node with url='https://example.com' and text='Example'" + def test_gchat_custom_link_syntax_inside_code_span_stays_literal(self): + """`` inside inline or fenced code is user content, not a + link. The AST-placeholder substitution must restore the original + syntax in code nodes rather than embedding the `\\ue000LINK...` + sentinel.""" + converter = _converter() + + def _values_of_type(ast: object, target: str) -> list[str]: + out: list[str] = [] + + def _walk(node: object) -> None: + if isinstance(node, dict): + if node.get("type") == target: + out.append(node.get("value", "")) + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + return out + + ast_inline = converter.to_ast("Use `` in code") + assert _values_of_type(ast_inline, "inlineCode") == [""] + + ast_fenced = converter.to_ast("```\ncurl \n```") + fenced_values = _values_of_type(ast_fenced, "code") + assert any("" in v for v in fenced_values), fenced_values + + def test_gchat_custom_link_tolerates_out_of_range_placeholder(self): + """If user input happens to include the PUA placeholder pattern with + an index that isn't in our `links` list, `to_ast` must not raise. The + unknown placeholder is preserved as literal text and any real + `` alongside it still parses correctly.""" + converter = _converter() + # Index 999 is deliberately out of range; there's only one real + # token so `links` will have length 1. + ast = converter.to_ast("\ue000LINK999\ue000 and ") + link_urls: list[str] = [] + + def _walk(node: object) -> None: + if isinstance(node, dict): + if node.get("type") == "link": + link_urls.append(node.get("url", "")) + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + assert link_urls == ["https://example.com"] + def test_gchat_custom_link_parses_url_with_balanced_parens(self): """URLs containing `(...)` (e.g. Wikipedia-style) must round-trip intact. The Markdown parser doesn't implement CommonMark's balanced- From ef7589e8722f373975940ab7348e07ab18d66391 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:03:32 +0000 Subject: [PATCH 25/40] test: close the gaps the self-review surfaced Four targeted regression tests, each justified by what it would catch: 1. test_should_swallow_placeholder_clear_error_on_strict_adapter (test_thread_faithful.py) Closes the meaningful gap: the try/except wrap added in 8dd34d1 for adapters like Telegram that reject whitespace-only edit_message was untested. Mock adapter now raises ValueError when the placeholder-clear " " edit comes through; the test asserts (a) thread.post() returns a SentMessage instead of propagating, (b) exactly one clear attempt was made (no retry loop), and (c) the warn log fires with the exact expected message. Mutation-tested: narrowing the `except Exception` to `except RuntimeError` makes this test fail, confirming the swallow path is actually exercised. 2. test_should_rebind_adapter_when_data_is_already_a_channelimpl (test_channel_faithful.py) Symmetric with the ThreadImpl rebind regression in test_serialization.py. ChannelImpl.from_json had the same early-return-skips-rebinding bug fix in 1ce9cbf, but no test was added for the channel path. 3. test_strips_gchat_custom_link_with_balanced_parens_in_url (test_gchat_format_extended.py) Pins the plain-text-extraction behavior for URLs containing `)`. Worked already because the strip regex stops at `|`/`>`, but a future regex tweak could regress this without failing any other test. 4. test_gchat_multiple_custom_links_in_one_paragraph (test_gchat_format_extended.py) Asserts the exact (text, link, text, link, text, link, text) sequence for a paragraph with three `` tokens. Catches off-by-one errors in the placeholder-substitution split logic that would otherwise pass single-link tests but produce wrong ordering or dropped fragments. 3450 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- tests/test_channel_faithful.py | 24 ++++++++++++++ tests/test_gchat_format_extended.py | 50 +++++++++++++++++++++++++++++ tests/test_thread_faithful.py | 49 ++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index 981704c..9099717 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -669,6 +669,30 @@ def test_should_sync_adapter_name_when_explicit_adapter_is_bound(self): assert channel.adapter.name == "teams" assert channel.to_json()["adapterName"] == "teams" + def test_should_rebind_adapter_when_data_is_already_a_channelimpl(self): + """Idempotent path: when ``data`` is already a ChannelImpl (e.g. revived + via ``object_hook``), passing an explicit ``adapter=`` must still rebind + it — an early-return shortcut would leave ``_adapter`` stale. Symmetric + with the ThreadImpl regression in test_serialization.py.""" + from chat_sdk.testing import create_mock_adapter as _create + + first = _create("slack") + second = _create("teams") + original = ChannelImpl.from_json( + { + "_type": "chat:Channel", + "id": "C123", + "adapter_name": "slack", + "is_dm": False, + }, + first, + ) + rebound = ChannelImpl.from_json(original, second) + + # Rebind applied even though data was already a ChannelImpl: + assert rebound.adapter.name == "teams" + assert rebound.to_json()["adapterName"] == "teams" + # =========================================================================== # deriveChannelId (tested in channel.test.ts alongside ChannelImpl) diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 288f565..2782ee1 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -127,6 +127,45 @@ def _walk(node: object) -> None: _walk(ast) assert link_urls == ["https://example.com"] + def test_gchat_multiple_custom_links_in_one_paragraph(self): + """A single paragraph with multiple `` tokens must produce + the right number of link nodes in the right order, with the + surrounding text correctly split between them. Catches regressions + in the placeholder-substitution split logic (off-by-one, dropped + text, wrong link order).""" + converter = _converter() + ast = converter.to_ast( + "Contact , , or ." + ) + + # Collect (text, link) sequence in document order. + flat: list[tuple[str, dict | str]] = [] + + def _walk(node: object) -> None: + if isinstance(node, dict): + ntype = node.get("type") + if ntype == "text": + flat.append(("text", node.get("value", ""))) + elif ntype == "link": + children = node.get("children", []) or [] + label = "".join(c.get("value", "") for c in children if isinstance(c, dict)) + flat.append(("link", {"url": node.get("url", ""), "text": label})) + else: + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + + assert flat == [ + ("text", "Contact "), + ("link", {"url": "mailto:a@example.com", "text": "Alice"}), + ("text", ", "), + ("link", {"url": "mailto:b@example.com", "text": "Bob"}), + ("text", ", or "), + ("link", {"url": "https://example.com", "text": "the site"}), + ("text", "."), + ] + def test_gchat_custom_link_parses_url_with_balanced_parens(self): """URLs containing `(...)` (e.g. Wikipedia-style) must round-trip intact. The Markdown parser doesn't implement CommonMark's balanced- @@ -425,6 +464,17 @@ def test_strips_gchat_custom_link_to_label(self): == "See Example Site for details" ) + def test_strips_gchat_custom_link_with_balanced_parens_in_url(self): + """The plain-text path must also strip cleanly when the URL contains + unescaped `)`. Regex stops at `|`/`>`, not `)`, so the label is what + survives — but pin it explicitly so a future regex tweak can't + regress this without failing the test.""" + converter = _converter() + assert ( + converter.extract_plain_text("See for info") + == "See Wiki for info" + ) + # --------------------------------------------------------------------------- # render_postable diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 102acfe..dd215ca 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -416,6 +416,55 @@ async def test_should_clear_placeholder_when_stream_is_whitespace_only(self): assert isinstance(final_edit[2], PostableMarkdown) assert final_edit[2].markdown == " " + # Python-specific regression: when the placeholder-clear edit_message(" ") + # raises (e.g. Telegram rejects whitespace-only content with a + # ValidationError), `_fallback_stream` must log + swallow the error so + # `thread.post()` still returns a SentMessage. The previous test pinned + # the happy path; this one pins the defensive try/except added in + # commit 8dd34d1 specifically for adapters that reject blank text. + @pytest.mark.asyncio + async def test_should_swallow_placeholder_clear_error_on_strict_adapter(self): + adapter = create_mock_adapter() + state = create_mock_state() + logger = MockLogger() + + # Telegram's adapter raises ValidationError when text.strip() is empty. + # Simulate by intercepting edit_message and raising on the " " payload. + clear_attempts: list[PostableMarkdown] = [] + original_edit = adapter.edit_message + + async def strict_edit(thread_id: str, message_id: str, message: Any) -> RawMessage: + if isinstance(message, PostableMarkdown) and not message.markdown.strip(): + clear_attempts.append(message) + raise ValueError("Message text cannot be empty") + return await original_edit(thread_id, message_id, message) + + adapter.edit_message = strict_edit # type: ignore[assignment] + + thread = _make_thread(adapter, state, logger=logger) + # Whitespace-only stream triggers the placeholder-clear branch. + text_stream = _create_text_stream([" ", "\n", " \n"]) + + # Must not raise — the SDK should log and fall through to the + # upstream "leave placeholder visible" behavior on rejection. + sent = await thread.post(text_stream) + + # Placeholder was posted, then exactly one clear edit was attempted + # (and rejected). No retry, no infinite loop. + assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") + assert len(clear_attempts) == 1 + assert clear_attempts[0].markdown == " " + # The warn log fired with the expected message (asserting the exact + # string so a refactor that changes the log can't silently break the + # observability contract). + assert any( + call[0] == "fallbackStream placeholder-clear edit failed; placeholder will remain visible" + for call in logger.warn.calls + ), [c[0] for c in logger.warn.calls] + # Stream contract still holds — we got a SentMessage back. + assert sent is not None + assert sent.id == "msg-1" + # it("should handle empty stream with disabled placeholder") @pytest.mark.asyncio async def test_should_handle_empty_stream(self): From f9a925d2c51e86ddb3aeafed67d7635dfaab1726 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:20:44 +0000 Subject: [PATCH 26/40] fix(gchat): 3 round-trip corrections from Codex P2s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Placeholder collision (to_ast): user text literally containing the old fixed-format placeholder `\ue000LINK0\ue000` alongside any real would rewrite the user's literal as a duplicate link node. Per-call random nonce (`secrets.token_hex(4)`) now makes placeholder tokens unforgeable — only the tokens emitted by this to_ast call will match its own injection regex. 2. Code-literal corruption (extract_plain_text): the new strip ran after backtick-stripping, so `` in inline/fenced code came back as just "Example". Fix: stash code-span contents behind nonce'd placeholders first, run the other strips on the surrounding text, then restore code contents literally at the end. Mirrors the approach already used in to_ast. 3. Relative URLs (_node_to_gchat): emitted for every link regardless of whether the URL had a scheme, but the reverse parsers only match `scheme:` prefixed URLs. `[doc](/docs)` went out as `` and came back as raw text. Fix: fall back to `text (url)` form when the URL has no scheme (joins the existing fallback set alongside `|`/`>`/`]`/newline in labels). 3 new regression tests: - test_gchat_custom_link_tolerates_in_range_forged_placeholder - test_preserves_gchat_custom_link_literal_inside_{inline,fenced}_code - test_falls_back_to_parenthesized_form_for_urls_without_a_scheme Total: 3454 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 119 ++++++++++++------ tests/test_gchat_format_extended.py | 62 +++++++++ 2 files changed, 142 insertions(+), 39 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 5077a56..08fec7f 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -14,6 +14,7 @@ from __future__ import annotations import re +import secrets from chat_sdk.shared.base_format_converter import ( BaseFormatConverter, @@ -24,26 +25,40 @@ ) _GCHAT_LINK_RE = re.compile(r"<([a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+)\|([^>]+)>") -# Private-use code point chosen as a placeholder token. PUA characters are -# guaranteed never to appear in user-authored text, so the round-trip -# substitution (emit placeholder → parse Markdown → inject link nodes) can -# use them as unique markers without collision risk. -_GCHAT_LINK_PLACEHOLDER = "\ue000LINK{idx}\ue000" -_GCHAT_LINK_PLACEHOLDER_RE = re.compile(r"\ue000LINK(\d+)\ue000") - - -def _inject_link_placeholders(node: dict, links: list[tuple[str, str]]) -> None: - """Walk the AST in place and expand ``\\ue000LINK{idx}\\ue000`` placeholders - in text nodes into real link nodes. Used by ``to_ast`` to bypass the - Markdown parser for custom ```` tokens whose URLs may contain - characters the parser can't round-trip (e.g. unescaped ``)``). - - Code spans (``inlineCode`` / ``code``) are handled specially: their content - is literal, so placeholders in them are rewritten back to the original - ```` syntax rather than being turned into link nodes. Out-of- - range placeholder indices (which can appear if crafted/accidental user - input happens to match the placeholder pattern) are left as literal text - rather than raising ``IndexError``. +_HAS_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:") + + +def _new_link_placeholder_token() -> tuple[str, re.Pattern[str]]: + """Return a fresh (format-string, regex) pair for link placeholders. + + Each ``to_ast`` call generates its own random nonce so placeholder tokens + can't be forged by user-supplied input that happens to contain a literal + ``\\ue000LINK{N}\\ue000`` sequence. Without the nonce, a message like + ``"\\ue000LINK0\\ue000 and "`` would rewrite the user's + literal placeholder into a duplicate link node. + """ + nonce = secrets.token_hex(4) + fmt = f"\ue000LINK{{idx}}-{nonce}\ue000" + pattern = re.compile(rf"\ue000LINK(\d+)-{nonce}\ue000") + return fmt, pattern + + +def _inject_link_placeholders( + node: dict, + links: list[tuple[str, str]], + placeholder_re: re.Pattern[str], +) -> None: + """Walk the AST in place and expand placeholder tokens in text nodes into + real link nodes. Used by ``to_ast`` to bypass the Markdown parser for + custom ```` tokens whose URLs may contain characters the parser + can't round-trip (e.g. unescaped ``)``). + + Code spans (``inlineCode`` / ``code``) are handled specially: their + content is literal, so placeholders in them are rewritten back to the + original ```` syntax rather than being turned into link nodes. + Out-of-range placeholder indices (which can happen with crafted input + that guessed the nonce) are left as literal text rather than raising + ``IndexError``. """ def _placeholder_to_literal(value: str) -> str: @@ -54,7 +69,7 @@ def _sub(match: re.Match[str]) -> str: return f"<{url}|{text}>" return match.group(0) - return _GCHAT_LINK_PLACEHOLDER_RE.sub(_sub, value) + return placeholder_re.sub(_sub, value) children = node.get("children") if not isinstance(children, list): @@ -70,7 +85,7 @@ def _sub(match: re.Match[str]) -> str: if "\ue000" not in value: new_children.append(child) continue - parts = _GCHAT_LINK_PLACEHOLDER_RE.split(value) + parts = placeholder_re.split(value) # parts alternates plain-text / placeholder-index / plain-text / ... for i, part in enumerate(parts): if i % 2 == 0: @@ -99,7 +114,7 @@ def _sub(match: re.Match[str]) -> str: child["value"] = _placeholder_to_literal(child["value"]) new_children.append(child) else: - _inject_link_placeholders(child, links) + _inject_link_placeholders(child, links, placeholder_re) new_children.append(child) node["children"] = new_children @@ -131,12 +146,17 @@ def to_ast(self, platform_text: str) -> Root: # extract each match to a PUA placeholder, parse the rest as # Markdown, and inject real link nodes where the placeholders # land. Accepts any RFC 3986 scheme. + # + # The placeholder carries a random nonce so user-supplied text that + # happens to look like a placeholder (e.g. literal `\ue000LINK0\ue000` + # content) can't be rewritten as a fake link. links: list[tuple[str, str]] = [] + placeholder_fmt, placeholder_re = _new_link_placeholder_token() def _capture(match: re.Match[str]) -> str: idx = len(links) links.append((match.group(1), match.group(2))) - return _GCHAT_LINK_PLACEHOLDER.format(idx=idx) + return placeholder_fmt.format(idx=idx) markdown = _GCHAT_LINK_RE.sub(_capture, markdown) @@ -149,18 +169,32 @@ def _capture(match: re.Match[str]) -> str: # Italic and code are the same format as markdown ast = parse_markdown(markdown) if links: - _inject_link_placeholders(ast, links) + _inject_link_placeholders(ast, links, placeholder_re) return ast def extract_plain_text(self, platform_text: str) -> str: """Extract plain text from Google Chat formatted text. - Strips formatting markers while preserving the text content. + Strips formatting markers while preserving the text content. Code + spans are treated as literal — any `` or formatting markers + inside them are preserved verbatim, consistent with ``to_ast``. """ - # Remove code blocks - result = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`").strip(), platform_text) - # Inline code - result = re.sub(r"`([^`]+)`", r"\1", result) + # Stash code-span contents behind nonce'd placeholders BEFORE running + # the other strips, then restore them at the end. Without this, the + # link-syntax strip below would mangle code like `` into + # just `text`. Same unforgeable-nonce strategy as to_ast. + nonce = secrets.token_hex(4) + code_contents: list[str] = [] + code_placeholder_re = re.compile(rf"\ue001CODE(\d+)-{nonce}\ue001") + + def _stash_code(match: re.Match[str]) -> str: + code_contents.append(match.group(1)) + return f"\ue001CODE{len(code_contents) - 1}-{nonce}\ue001" + + # Fenced first, then inline (order matters so we don't double-match). + result = re.sub(r"```([\s\S]*?)```", _stash_code, platform_text) + result = re.sub(r"`([^`]+)`", _stash_code, result) + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Google Chat custom link syntax: -> text (any RFC 3986 scheme) result = re.sub(r"<[a-zA-Z][a-zA-Z0-9+.\-]*:[^|\s>]+\|([^>]+)>", r"\1", result) @@ -168,8 +202,15 @@ def extract_plain_text(self, platform_text: str) -> str: result = re.sub(r"\*([^*]+)\*", r"\1", result) # Italic markers (_text_) result = re.sub(r"_([^_]+)_", r"\1", result) - # Strikethrough markers (~text~) + # Strikethrough markers (~text|) result = re.sub(r"~([^~]+)~", r"\1", result) + + # Restore code contents literally. + def _restore(match: re.Match[str]) -> str: + i = int(match.group(1)) + return code_contents[i] if 0 <= i < len(code_contents) else match.group(0) + + result = code_placeholder_re.sub(_restore, result) return result.strip() def _node_to_gchat(self, node: Content) -> str: @@ -212,14 +253,14 @@ def _node_to_gchat(self, node: Content) -> str: if link_text == url: return url # Divergence from upstream — see docs/UPSTREAM_SYNC.md. - # Labels containing `|`, `>`, `]`, or a newline can't be emitted - # safely in form: Google Chat (and our own round-trip - # regex) stops at the first `>` / `|`, and `]` prematurely closes - # the label when to_ast() converts `` to Markdown - # `[text](url)` form. Fall back to plain `text (url)` so the label - # text is preserved and Google Chat's auto-link detection still - # makes the URL clickable. - if any(c in link_text for c in ("|", ">", "]", "\n")): + # Fall back to plain `text (url)` when the `` form + # can't safely round-trip: + # - labels containing `|`, `>`, `]`, or a newline would be + # truncated on the platform or by our own parser; + # - URLs without a scheme (e.g. relative paths like `/docs`) + # won't match the reverse parser's scheme-prefixed regex, + # so the link node would be lost on the read side. + if any(c in link_text for c in ("|", ">", "]", "\n")) or not _HAS_SCHEME_RE.match(url): return f"{link_text} ({url})" return f"<{url}|{link_text}>" diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 2782ee1..35d8aad 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -106,6 +106,29 @@ def _walk(node: object) -> None: fenced_values = _values_of_type(ast_fenced, "code") assert any("" in v for v in fenced_values), fenced_values + def test_gchat_custom_link_tolerates_in_range_forged_placeholder(self): + """User text containing the literal PUA placeholder pattern with an + in-range index could previously be rewritten as a duplicate link + node (content corruption). The per-call nonce added to the + placeholder makes it unforgeable, so the literal stays literal and + only the real `` produces a link.""" + converter = _converter() + # Old format was `\ue000LINK0\ue000`. Feeding that exact string + # alongside a real link used to resolve to links[0] twice. + ast = converter.to_ast("\ue000LINK0\ue000 and ") + link_urls: list[str] = [] + + def _walk(node: object) -> None: + if isinstance(node, dict): + if node.get("type") == "link": + link_urls.append(node.get("url", "")) + for child in node.get("children", []) or []: + _walk(child) + + _walk(ast) + # Exactly one link — the real , not the forged placeholder. + assert link_urls == ["https://example.com"] + def test_gchat_custom_link_tolerates_out_of_range_placeholder(self): """If user input happens to include the PUA placeholder pattern with an index that isn't in our `links` list, `to_ast` must not raise. The @@ -324,6 +347,21 @@ def test_falls_back_to_parenthesized_form_when_label_contains_reserved_chars(sel } assert converter.from_ast(ok_ast) == "" + def test_falls_back_to_parenthesized_form_for_urls_without_a_scheme(self): + """URLs without an RFC 3986 scheme (relative paths like `/docs`, + fragment-only anchors like `#section`, protocol-relative like + `//example.com/x`) can't round-trip through `` because + the reverse parsers only recognize scheme-prefixed URLs. Fall back + to the plain `text (url)` form so the link survives as readable + text and Google Chat's auto-link detection still fires where it + can. Regression for a Codex P2.""" + converter = _converter() + for url in ["/docs", "#section", "../relative", "//example.com/x"]: + result = converter.from_markdown(f"[doc]({url})") + assert result == f"doc ({url})", f"url={url!r}" + # Sanity: absolute URLs still use the form. + assert converter.from_markdown("[doc](https://example.com)") == "" + def test_blockquote(self): converter = _converter() ast = converter.to_ast("> quoted text") @@ -475,6 +513,30 @@ def test_strips_gchat_custom_link_with_balanced_parens_in_url(self): == "See Wiki for info" ) + def test_preserves_gchat_custom_link_literal_inside_inline_code(self): + """Code spans are literal — a `` inside backticks is user + content, not a link. The link-strip regex runs on the surrounding + text only; code contents are preserved verbatim (just without the + backticks). Regression test for a P2 Codex finding: before the fix, + the strip-inline-code pass ran first and exposed the link syntax to + the link-strip pass, which collapsed it to just the label.""" + converter = _converter() + assert ( + converter.extract_plain_text("Use `` here") + == "Use here" + ) + + def test_preserves_gchat_custom_link_literal_inside_fenced_code(self): + """Same as inline code, but for fenced ``` blocks. Content between + the fences must be preserved verbatim.""" + converter = _converter() + result = converter.extract_plain_text("Here is the code:\n```\ncurl \n```\nDone.") + assert "" in result + # And the label-only form should NOT appear (would indicate the strip + # pass leaked into code content). + assert ">example<" not in result + assert " example " not in result + # --------------------------------------------------------------------------- # render_postable From 94ee17ada81471dc63dde2f87419eeae540a8831 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:29:11 +0000 Subject: [PATCH 27/40] docs: add Self-Review Discipline section to CLAUDE.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Honest retro from this PR: review bots (Codex, CodeRabbit) surfaced ~13 valid findings; my own self-reviews surfaced ~3–4. Bots aren't magic — they run adversarial checks consistently. The agent guidelines didn't tell future sessions to do the same. Adds a new "Self-Review Discipline" section between Principles and Port Rules, covering the six specific blindspots this PR kept hitting: 1. Adversarial input sweep — what inputs would break this regex/ substitution/tokenizer? 2. Emit/parse symmetry — does my parse accept every shape my emit produces? 3. Pass-interaction check — does my new pass compose cleanly with every existing pass in the pipeline? 4. Unforgeable sentinels — per-call nonce for any placeholder mapped back to structured data. 5. Divergence budget — enforce the 2-divergence-per-sync cap against myself, not just externally. 6. The Codex pre-ship question — "what would Codex find right now?" Also cross-links from UPSTREAM_SYNC.md "How to land a divergence" since divergence code is the highest-risk surface for these blindspots. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CLAUDE.md | 44 +++++++++++++++++++++++++++++++++++++++++++ docs/UPSTREAM_SYNC.md | 4 ++++ 2 files changed, 48 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 7b3800c..05cc4b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,50 @@ All thread IDs follow: `{adapter}:{channel}:{thread}` 3. **No two tests should verify the same thing.** Duplicates inflate counts without catching more bugs. +## Self-Review Discipline + +Review bots (Codex, CoderRabbit) outperformed agent self-review ~3× on recent +PRs — not because they're magic, but because they run adversarial checks +consistently. Run these yourself *before* declaring code ready. They target +the specific blindspots that have cost us silent bugs. + +1. **Adversarial input sweep.** For every regex, substitution, or tokenization + pass you add, enumerate ~5 inputs that could break it: + - empty input, single-char input + - input containing the marker/sentinel your code produces (forgery) + - input containing each delimiter your pattern uses + - the pattern appearing in unintended contexts (code spans, quoted strings) + - very long input, odd unicode, PUA code points + +2. **Emit/parse symmetry.** If you add `X → Y` (emit), verify your `Y → X` + (parse) accepts every `X` the emit might produce. Every URL scheme, every + label shape, every edge case (empty / relative / None). Asymmetry between + emit and parse is a common source of silent data loss. + +3. **Pass-interaction check.** When inserting a new transformation pass into + a pipeline (markdown parsing, stripping, rendering), walk through every + existing pass: does yours break any? do any break yours? does ORDER + matter? Example: a link-strip inserted after code-stripping corrupts code + content containing link syntax. + +4. **Unforgeable sentinels.** Any placeholder token that later code maps + back to structured data must carry a per-call nonce + (`secrets.token_hex(n)`). Fixed tokens like `\ue000LINK0\ue000` can be + forged by user input. Also tolerate out-of-range / unrecognized + sentinels without raising — fall back to literal text. + +5. **Divergence budget.** `docs/UPSTREAM_SYNC.md` sets a max of **2 + divergences per sync**. Enforce this against yourself. A 3rd divergence + = stop and ask whether this is still a sync or becoming a fork. Document + every divergence in the non-parity table, add a breadcrumb at the code + site, write a regression test that would fail if someone "fixes" the + divergence back to upstream. + +6. **The Codex pre-ship question.** Before declaring ready, ask yourself: + "What would Codex find in this code right now?" Be adversarial. If the + honest answer is "probably X," test X first. Don't wait for a bot to + file it. + ## Port Rules (TS → Python) See docs/UPSTREAM_SYNC.md for the full 15-hazard guide. Key patterns: diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 05d818a..0dcd298 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -111,6 +111,10 @@ the rules below before adding one. back to upstream's behavior. The test's docstring should cite the reason. 5. **CHANGELOG entry** under a "Python-specific (divergence from upstream)" subsection. +6. **Run the self-review adversarial checks** from + [CLAUDE.md § Self-Review Discipline](../CLAUDE.md#self-review-discipline). + Divergence code is exactly the kind of novel, Python-specific logic that + bot reviewers consistently find bugs in — catch them yourself first. ### Review signal From a57237951695e0f1a20cfc0605ee98a47547a08d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:31:53 +0000 Subject: [PATCH 28/40] docs: split Self-Review Discipline into dedicated docs/SELF_REVIEW.md The 6-point discipline was too long to inline in CLAUDE.md and was going to grow. Extracted to its own file following the same pattern CLAUDE.md already uses for UPSTREAM_SYNC.md (compact pointer in the root agent doc, full content in docs/). Also reworked the content to be principle-based rather than tied to specific findings from the 4.26 sync PR. Each principle describes a *class* of bug; specific examples belong in commit messages and PR descriptions, not in the ongoing guidelines doc. UPSTREAM_SYNC.md's "How to land a divergence" checklist now points at the new location. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CLAUDE.md | 47 +++--------------- docs/SELF_REVIEW.md | 110 ++++++++++++++++++++++++++++++++++++++++++ docs/UPSTREAM_SYNC.md | 6 +-- 3 files changed, 119 insertions(+), 44 deletions(-) create mode 100644 docs/SELF_REVIEW.md diff --git a/CLAUDE.md b/CLAUDE.md index 05cc4b2..b081cbe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,47 +66,12 @@ All thread IDs follow: `{adapter}:{channel}:{thread}` ## Self-Review Discipline -Review bots (Codex, CoderRabbit) outperformed agent self-review ~3× on recent -PRs — not because they're magic, but because they run adversarial checks -consistently. Run these yourself *before* declaring code ready. They target -the specific blindspots that have cost us silent bugs. - -1. **Adversarial input sweep.** For every regex, substitution, or tokenization - pass you add, enumerate ~5 inputs that could break it: - - empty input, single-char input - - input containing the marker/sentinel your code produces (forgery) - - input containing each delimiter your pattern uses - - the pattern appearing in unintended contexts (code spans, quoted strings) - - very long input, odd unicode, PUA code points - -2. **Emit/parse symmetry.** If you add `X → Y` (emit), verify your `Y → X` - (parse) accepts every `X` the emit might produce. Every URL scheme, every - label shape, every edge case (empty / relative / None). Asymmetry between - emit and parse is a common source of silent data loss. - -3. **Pass-interaction check.** When inserting a new transformation pass into - a pipeline (markdown parsing, stripping, rendering), walk through every - existing pass: does yours break any? do any break yours? does ORDER - matter? Example: a link-strip inserted after code-stripping corrupts code - content containing link syntax. - -4. **Unforgeable sentinels.** Any placeholder token that later code maps - back to structured data must carry a per-call nonce - (`secrets.token_hex(n)`). Fixed tokens like `\ue000LINK0\ue000` can be - forged by user input. Also tolerate out-of-range / unrecognized - sentinels without raising — fall back to literal text. - -5. **Divergence budget.** `docs/UPSTREAM_SYNC.md` sets a max of **2 - divergences per sync**. Enforce this against yourself. A 3rd divergence - = stop and ask whether this is still a sync or becoming a fork. Document - every divergence in the non-parity table, add a breadcrumb at the code - site, write a regression test that would fail if someone "fixes" the - divergence back to upstream. - -6. **The Codex pre-ship question.** Before declaring ready, ask yourself: - "What would Codex find in this code right now?" Be adversarial. If the - honest answer is "probably X," test X first. Don't wait for a bot to - file it. +Before declaring any change ready, run the adversarial checks in +[docs/SELF_REVIEW.md](docs/SELF_REVIEW.md). In short: input sweeps, +emit/parse symmetry, pass-interaction, unforgeable sentinels, divergence +budget, and the pre-ship "what would an adversarial reviewer find?" +question. Apply these especially to novel logic, new regex/substitution +passes, and anything that lands as a divergence from upstream. ## Port Rules (TS → Python) diff --git a/docs/SELF_REVIEW.md b/docs/SELF_REVIEW.md new file mode 100644 index 0000000..8edb8a0 --- /dev/null +++ b/docs/SELF_REVIEW.md @@ -0,0 +1,110 @@ +# Self-Review Discipline + +Automated reviewers (Codex, CodeRabbit, CI linters) catch bugs not because +they're smarter than humans or agents, but because they apply adversarial +checks consistently on every diff. Self-review tends to verify happy paths +and ship. This doc is the set of adversarial checks to run against your own +code *before* declaring a change ready. + +## When to apply + +Any change that introduces novel logic — especially: + +- New regex, substitution, or tokenization pass +- New emit/parse code path (serialization, format conversion) +- New transformation pass inserted into an existing pipeline +- Divergence from upstream (anything that lands in the non-parity table + in `docs/UPSTREAM_SYNC.md`) +- Any time you're about to say "this is ready" or "ship it" + +Ports of straightforward upstream code that upstream has already reviewed +need less adversarial attention. The risk scales with how much *new* +Python-specific logic you're introducing. + +## The principles + +### 1. Adversarial input sweep + +For every regex, substitution, or tokenization pass, enumerate inputs that +could break it: + +- Empty input, single-character input +- Input containing the marker/sentinel your code produces (forgery) +- Input containing each delimiter your pattern uses +- The pattern appearing in unintended contexts (code spans, quoted + strings, comments, nested escapes) +- Very long input, unusual whitespace, private-use (PUA) code points, + newlines, control characters + +If any of these would break your code, fix or guard against it before +shipping. + +### 2. Emit/parse symmetry + +If you add `X → Y` (emit), verify your `Y → X` (parse) accepts every `X` +the emit might produce: + +- Every scheme/format your emit outputs +- Every edge-case shape (empty, relative, None, whitespace-only, + malformed) +- Every encoding, escape, or quoting your emit might apply + +Asymmetry between emit and parse is a common source of silent data loss — +the emit produces something the parse doesn't recognize, and the +round-trip quietly drops or corrupts data. + +### 3. Pass-interaction check + +When inserting a new transformation pass into a pipeline (Markdown +parsing, plain-text stripping, AST walking, card rendering), walk through +every existing pass and ask: + +- Does my new pass break any existing pass? +- Does any existing pass break my new pass? +- Does the ORDER of passes matter? Am I in the right slot? + +A pass that works in isolation can produce wrong output when composed +with neighbors, especially when both operate on overlapping patterns. + +### 4. Unforgeable sentinels + +Any placeholder or sentinel token that later code maps back to structured +data must carry a per-call nonce (`secrets.token_hex(n)`). Fixed tokens +can be forged by user-supplied input that happens to match the pattern, +turning user content into structured data or crashing the parser. + +Also: tolerate out-of-range or unrecognized sentinels without raising. +Crafted input that guesses the nonce should fall back to literal text, +not propagate exceptions up through the stack. + +### 5. Divergence budget + +`docs/UPSTREAM_SYNC.md` sets a max of **2 divergences per sync PR**. +Enforce this against yourself, not just externally. A 3rd divergence = +stop and ask whether this is still a sync or becoming a fork. + +Every divergence must have: +- A row in the non-parity table (`docs/UPSTREAM_SYNC.md`) +- A breadcrumb at the code site: `# Divergence from upstream — see docs/UPSTREAM_SYNC.md` +- A regression test that would fail if someone "fixes" the divergence + back to upstream's behavior +- A CHANGELOG entry under "Python-specific (divergence from upstream)" + +### 6. The pre-ship question + +Before declaring any change ready, ask yourself: + +> *What would an adversarial reviewer find in this code right now?* + +Be honest. If the answer is "probably nothing," ship. If the answer is +"maybe X or Y," test X and Y first — don't wait for a bot to file them. + +This is the highest-leverage single check. Five minutes of honest +adversarial self-review catches most bot findings. + +## Maintenance + +If future PRs accumulate high bot-finding counts relative to self-surfaced +findings, revisit this doc and add a new principle or sharpen an existing +one. Each principle should describe a *class* of bug, not a specific +instance — specifics belong in commit messages and PR descriptions. diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 0dcd298..7a2db66 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -112,9 +112,9 @@ the rules below before adding one. 5. **CHANGELOG entry** under a "Python-specific (divergence from upstream)" subsection. 6. **Run the self-review adversarial checks** from - [CLAUDE.md § Self-Review Discipline](../CLAUDE.md#self-review-discipline). - Divergence code is exactly the kind of novel, Python-specific logic that - bot reviewers consistently find bugs in — catch them yourself first. + [docs/SELF_REVIEW.md](SELF_REVIEW.md). Divergence code is exactly the + kind of novel, Python-specific logic that bot reviewers consistently + find bugs in — catch them yourself first. ### Review signal From f4c6253bf0d7d5cbfc590e4bf6d0a5d3a6735050 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:43:25 +0000 Subject: [PATCH 29/40] fix(gchat, thread): 3 bugs found by self-review subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three subagents applied docs/SELF_REVIEW.md's six principles to this PR's novel code. Between them they found three valid bugs that the review bots hadn't surfaced. The discipline works — writing it down and running it deliberately pays off. [P2] to_ast bold/strike pre-pass corrupts code-span content (pass-interaction subagent) Input `` `*foo*` `` came back as inlineCode with value `**foo**`: the bold pre-parse substitution rewrote `*foo*` → `**foo**` on the raw text, and parse_markdown then wrapped the doubled markers verbatim in the code node. Same class as the Codex finding on extract_plain_text, just in a different function. Fix: stash code-span contents behind nonce'd placeholders BEFORE the bold/strike passes, restore in the walker. The inline regex also needed (?` which parse rejects (emit/parse-symmetry subagent) An AST link node with empty children produced ``, which our parse regex requires ≥1 char in the label — so the output was raw angle brackets visible in the rendered message. Fix: emit the bare URL when the label is empty. Google Chat auto-links it and nothing informational is lost. 3 new regression tests, each mutation-testable: - test_to_ast_preserves_bold_and_strike_markers_inside_code_spans - test_should_flush_and_cleanup_when_stream_raises_midway - test_emits_bare_url_when_link_label_is_empty Total: 3457 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 95 +++++++++++++++---- src/chat_sdk/thread.py | 28 +++++- tests/test_gchat_format_extended.py | 61 ++++++++++++ tests/test_thread_faithful.py | 37 ++++++++ 4 files changed, 199 insertions(+), 22 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 08fec7f..6a43d0e 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -47,21 +47,26 @@ def _inject_link_placeholders( node: dict, links: list[tuple[str, str]], placeholder_re: re.Pattern[str], + code_contents: list[str] | None = None, + code_placeholder_re: re.Pattern[str] | None = None, ) -> None: - """Walk the AST in place and expand placeholder tokens in text nodes into - real link nodes. Used by ``to_ast`` to bypass the Markdown parser for - custom ```` tokens whose URLs may contain characters the parser - can't round-trip (e.g. unescaped ``)``). - - Code spans (``inlineCode`` / ``code``) are handled specially: their - content is literal, so placeholders in them are rewritten back to the - original ```` syntax rather than being turned into link nodes. - Out-of-range placeholder indices (which can happen with crafted input - that guessed the nonce) are left as literal text rather than raising - ``IndexError``. + """Walk the AST in place and expand placeholder tokens. + + Text-node placeholders for ```` are expanded into real link + nodes. ``inlineCode`` / ``code`` nodes are handled specially: + + - Any ``\\ue002CODE{idx}-{nonce}\\ue002`` placeholder in the node value + is restored to the original code content (which was stashed before + the bold / strikethrough pre-passes so those passes can't rewrite + marker characters inside code spans). + - Any stray link placeholder in the value is rewritten back to the + original ```` literal, since code-span content is literal. + + Out-of-range indices (which can happen with crafted input that guessed + a nonce) are left as literal text rather than raising ``IndexError``. """ - def _placeholder_to_literal(value: str) -> str: + def _link_placeholder_to_literal(value: str) -> str: def _sub(match: re.Match[str]) -> str: i = int(match.group(1)) if 0 <= i < len(links): @@ -71,6 +76,18 @@ def _sub(match: re.Match[str]) -> str: return placeholder_re.sub(_sub, value) + def _restore_code(value: str) -> str: + if code_contents is None or code_placeholder_re is None: + return value + + def _sub(match: re.Match[str]) -> str: + i = int(match.group(1)) + if 0 <= i < len(code_contents): + return code_contents[i] + return match.group(0) + + return code_placeholder_re.sub(_sub, value) + children = node.get("children") if not isinstance(children, list): return @@ -107,14 +124,18 @@ def _sub(match: re.Match[str]) -> str: # Preserve as literal text rather than raising. new_children.append({"type": "text", "value": f"\ue000LINK{idx}\ue000"}) elif ctype in ("inlineCode", "code") and isinstance(child.get("value"), str): - # Code spans are literal — a `` inside them is user - # content, not a link. Restore the original syntax rather than - # leaving the placeholder embedded in the code value. - if "\ue000" in child["value"]: - child["value"] = _placeholder_to_literal(child["value"]) + # Restore stashed code content (raw user bytes including any + # `*`/`~`/`` that the pre-parse passes would have + # otherwise mangled). + value = _restore_code(child["value"]) + # Also restore any stray link placeholder — code-span content + # is literal, so `` should stay as-is. + if "\ue000" in value: + value = _link_placeholder_to_literal(value) + child["value"] = value new_children.append(child) else: - _inject_link_placeholders(child, links, placeholder_re) + _inject_link_placeholders(child, links, placeholder_re, code_contents, code_placeholder_re) new_children.append(child) node["children"] = new_children @@ -135,7 +156,32 @@ def to_ast(self, platform_text: str) -> Root: Converts Google Chat format to standard markdown, then parses with the shared parser. """ - markdown = platform_text + # Stash code-span *contents* (keeping the surrounding backticks) + # behind nonce'd placeholders BEFORE running the bold/strike + # substitutions. Without this, input like `` `*foo*` `` would have + # the `*foo*` inside the code span rewritten to `**foo**` by the + # bold pass, and the user would see doubled asterisks in the + # resulting `inlineCode` node. The backticks stay, so the + # Markdown parser still produces proper `inlineCode` / `code` + # nodes; the walker restores the original content at the end. + code_nonce = secrets.token_hex(4) + code_contents: list[str] = [] + code_placeholder_re = re.compile(rf"\ue002CODE(\d+)-{code_nonce}\ue002") + + def _stash_code(match: re.Match[str]) -> str: + code_contents.append(match.group(2)) + return f"{match.group(1)}\ue002CODE{len(code_contents) - 1}-{code_nonce}\ue002{match.group(3)}" + + # Fenced first (``` ... ```), then inline (` ... `). Order matters: + # fenced-first avoids ``` being read as three adjacent inline spans. + # The fenced pattern requires a newline after the opening fence + # because our Markdown parser treats `` ``` TEXT ``` `` as a lang + # tag rather than a code block — preserving that newline keeps the + # placeholder'd form parseable the same way the original was. + # The inline pattern uses negative look-around to avoid matching + # backticks that are part of a (now placeholder'd) fence. + markdown = re.sub(r"(```[ \t]*\n)([\s\S]*?)(\n```)", _stash_code, platform_text) + markdown = re.sub(r"(?` has to survive our @@ -168,8 +214,8 @@ def _capture(match: re.Match[str]) -> str: # Italic and code are the same format as markdown ast = parse_markdown(markdown) - if links: - _inject_link_placeholders(ast, links, placeholder_re) + if links or code_contents: + _inject_link_placeholders(ast, links, placeholder_re, code_contents, code_placeholder_re) return ast def extract_plain_text(self, platform_text: str) -> str: @@ -252,6 +298,13 @@ def _node_to_gchat(self, node: Content) -> str: url = node.get("url", "") if link_text == url: return url + # An empty label can't round-trip in either `` or + # `text (url)` form (the parser regex requires ≥1 char in the + # label, and "(url)" alone reads as a parenthetical). Emit the + # bare URL — Google Chat auto-detects it as a link and no + # structure is lost beyond the empty label itself. + if not link_text: + return url # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Fall back to plain `text (url)` when the `` form # can't safely round-trip: diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 71b337b..9f88e1c 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -637,6 +637,7 @@ async def _edit_loop() -> None: if msg is not None: pending_edit = asyncio.create_task(_edit_loop()) + stream_error: BaseException | None = None try: async for chunk in text_stream: renderer.push(chunk) @@ -647,11 +648,29 @@ async def _edit_loop() -> None: thread_id_for_edits = msg.thread_id or self._id last_edit_content = content pending_edit = asyncio.create_task(_edit_loop()) + except BaseException as exc: + # Capture so we can run the cleanup + flush below even when the + # stream raises mid-iteration (e.g. LLM connection drops). The + # alternative — letting the exception propagate immediately — + # would leak pending_edit as an orphan task AND strand the + # placeholder as a forever-"..." message, which is exactly the + # pathology the placeholder-clear divergence was added to fix. + stream_error = exc finally: stop_event.set() if pending_edit is not None: - await pending_edit + try: + await pending_edit + except Exception as exc: + if self._logger: + self._logger.warn("fallbackStream edit-loop task raised", exc) + + # If the stream raised before we ever posted anything, there's no + # SentMessage to return and no placeholder to clean up — propagate + # the original error so the caller sees it. + if stream_error is not None and msg is None: + raise stream_error accumulated = renderer.get_text() final_content = renderer.finish() @@ -706,6 +725,13 @@ async def _edit_loop() -> None: if self._message_history is not None: await self._message_history.append(self._id, _to_message(sent)) + # If the stream raised mid-way, we've now flushed whatever partial + # content was rendered (so the user sees real content instead of a + # stranded "..."). Re-raise the original error so the caller still + # learns the stream failed. + if stream_error is not None: + raise stream_error + return sent # -- Typing indicator ---------------------------------------------------- diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index 35d8aad..c24a836 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -79,6 +79,41 @@ def _walk(node: object) -> None: _walk(ast) assert found, "Expected a link node with url='https://example.com' and text='Example'" + def test_to_ast_preserves_bold_and_strike_markers_inside_code_spans(self): + """Bold / strikethrough pre-parse passes must not rewrite marker + characters inside code spans. Without the code-stash pass that + protects them, `` `*foo*` `` would come back as inlineCode with + value `**foo**` (doubled asterisks) and `` `~bar~` `` as + `~~bar~~`. Same class of pass-interaction bug as the Codex + finding on `extract_plain_text`, caught by a subagent self-review.""" + converter = _converter() + + # Inline code with bold-ish markers + ast1 = converter.to_ast("Use `*foo*` here") + inline_values: list[str] = [] + + def _walk(node: object, target: str, out: list[str]) -> None: + if isinstance(node, dict): + if node.get("type") == target: + out.append(node.get("value", "")) + for child in node.get("children", []) or []: + _walk(child, target, out) + + _walk(ast1, "inlineCode", inline_values) + assert inline_values == ["*foo*"], inline_values + + # Inline code with strike-ish markers + ast2 = converter.to_ast("Use `~bar~` here") + inline_values2: list[str] = [] + _walk(ast2, "inlineCode", inline_values2) + assert inline_values2 == ["~bar~"], inline_values2 + + # Fenced code with a mix of markers + ast3 = converter.to_ast("```\n*bold* and ~strike~\n```") + fenced_values: list[str] = [] + _walk(ast3, "code", fenced_values) + assert any("*bold* and ~strike~" in v for v in fenced_values), fenced_values + def test_gchat_custom_link_syntax_inside_code_span_stays_literal(self): """`` inside inline or fenced code is user content, not a link. The AST-placeholder substitution must restore the original @@ -347,6 +382,32 @@ def test_falls_back_to_parenthesized_form_when_label_contains_reserved_chars(sel } assert converter.from_ast(ok_ast) == "" + def test_emits_bare_url_when_link_label_is_empty(self): + """A link node with empty children can't round-trip through + `` (parse regex requires ≥1 char in the label) or + `text (url)` (reads as a parenthetical). Emit just the URL so + Google Chat auto-links it — the empty label carried no + information anyway.""" + converter = _converter() + ast = { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "url": "https://example.com", + "children": [], + } + ], + } + ], + } + # No `` (would be visible as literal angle brackets) and no + # `(https://example.com)` parenthetical either. + assert converter.from_ast(ast) == "https://example.com" + def test_falls_back_to_parenthesized_form_for_urls_without_a_scheme(self): """URLs without an RFC 3986 scheme (relative paths like `/docs`, fragment-only anchors like `#section`, protocol-relative like diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index dd215ca..cbf6f3b 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -416,6 +416,43 @@ async def test_should_clear_placeholder_when_stream_is_whitespace_only(self): assert isinstance(final_edit[2], PostableMarkdown) assert final_edit[2].markdown == " " + # Python-specific regression: when a streaming source raises mid-stream + # (e.g. LLM connection drops), `_fallback_stream` must still flush the + # accumulated partial content + clean up the background edit task + # before re-raising. Without this, the placeholder stays stranded as + # "..." even though partial content was rendered, and pending_edit + # becomes an orphan task. This is the exact pathology the placeholder- + # clear divergence was designed to fix — but previously only ran on + # the happy path. + @pytest.mark.asyncio + async def test_should_flush_and_cleanup_when_stream_raises_midway(self): + adapter = create_mock_adapter() + state = create_mock_state() + logger = MockLogger() + + thread = _make_thread(adapter, state, streaming_update_interval_ms=10, logger=logger) + + stream_error = RuntimeError("LLM connection dropped") + + async def failing_stream() -> AsyncIterator[str]: + yield "Partial content\n" + await asyncio.sleep(0.05) # Let the edit loop commit the partial. + raise stream_error + + # Caller must still see the original exception — the flush is best- + # effort, not a swallow. + with pytest.raises(RuntimeError, match="LLM connection dropped"): + await thread.post(failing_stream()) + + # Placeholder was posted and the partial content was flushed via an + # edit. A regression that skipped cleanup would leave `_post_calls` + # with just "..." and no edits. + assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") + markdown_edits = [c for _, _, c in adapter._edit_calls if isinstance(c, PostableMarkdown)] + assert markdown_edits, "expected the edit loop to have flushed partial content before re-raise" + # The latest edit must contain the partial content the stream produced. + assert "Partial content" in markdown_edits[-1].markdown + # Python-specific regression: when the placeholder-clear edit_message(" ") # raises (e.g. Telegram rejects whitespace-only content with a # ValidationError), `_fallback_stream` must log + swallow the error so From c381a6347ed9f95eec6b40f0876edf62611eb7e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 00:45:57 +0000 Subject: [PATCH 30/40] fix(thread): narrow except in _fallback_stream from BaseException to Exception github-code-quality caught that the stream-error capture in f4c6253 used `except BaseException`, which would swallow CancelledError, KeyboardInterrupt, SystemExit, and GeneratorExit along with the normal runtime errors we want to flush around. Narrowed to `except Exception`. Control-flow exceptions now propagate immediately; the existing `finally: stop_event.set()` still signals the background edit loop to exit cleanly on cancellation, so there's no task leak on shutdown either. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/thread.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 9f88e1c..9fe2bad 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -637,7 +637,7 @@ async def _edit_loop() -> None: if msg is not None: pending_edit = asyncio.create_task(_edit_loop()) - stream_error: BaseException | None = None + stream_error: Exception | None = None try: async for chunk in text_stream: renderer.push(chunk) @@ -648,13 +648,20 @@ async def _edit_loop() -> None: thread_id_for_edits = msg.thread_id or self._id last_edit_content = content pending_edit = asyncio.create_task(_edit_loop()) - except BaseException as exc: + except Exception as exc: # Capture so we can run the cleanup + flush below even when the # stream raises mid-iteration (e.g. LLM connection drops). The # alternative — letting the exception propagate immediately — # would leak pending_edit as an orphan task AND strand the # placeholder as a forever-"..." message, which is exactly the # pathology the placeholder-clear divergence was added to fix. + # + # Control-flow exceptions (CancelledError, KeyboardInterrupt, + # SystemExit, GeneratorExit) are intentionally NOT caught — + # those signal shutdown and must propagate immediately. + # `finally: stop_event.set()` still fires on every exit path, + # so the background _edit_loop exits cleanly on cancellation + # too. stream_error = exc finally: stop_event.set() From 6168961f41310a296fec779f0f14e575cb3b8869 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 01:00:35 +0000 Subject: [PATCH 31/40] fix(serde): invalidate state/channel caches on idempotent adapter rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged a real P1 I introduced in the idempotent `from_json( existing_instance, adapter=X)` path: I updated `_adapter` and `_adapter_name` but left previously-populated caches alone. If the instance had been previously bound (state adapter cached, channel derived), the rebind would: - leave `_state_adapter_instance` pointing at the OLD chat's state backend, so `get_state` / `set_state` would silently route to the wrong backend; - leave `_channel_cache` referencing a channel built from the OLD adapter, so `thread.channel` operations would target the previous context. Both ThreadImpl and ChannelImpl were affected. Fix: when the `isinstance(data, ...)` idempotent branch fires AND we have an explicit `adapter=` or `chat=`, drop the relevant caches before the rebind block runs. The existing fresh-construction path sets `_state_adapter_instance = None` and defers to lazy singleton resolve, so clearing the cache here restores parity. 2 new regression tests: - test_should_invalidate_state_and_channel_caches_on_idempotent_rebind (thread — populates channel cache via `.channel` access, state cache via explicit binding, asserts both drop after rebind) - test_should_invalidate_state_cache_on_idempotent_rebind (channel — populates state cache, asserts it drops) 3459 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/channel.py | 7 +++++++ src/chat_sdk/thread.py | 10 ++++++++++ tests/test_channel_faithful.py | 29 +++++++++++++++++++++++++++++ tests/test_serialization.py | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index dcfca56..4a2d740 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -423,6 +423,13 @@ def from_json( """ if isinstance(data, ChannelImpl): channel = data + # Invalidate the state-adapter cache so the rebind block below + # resolves it fresh against the new adapter/chat. Without this, + # `get_state` / `set_state` calls would continue to route + # through the OLD chat's state backend even though the + # channel now reports the new adapter name. + if adapter is not None or chat is not None: + channel._state_adapter_instance = None else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 9fe2bad..1ff0092 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -811,6 +811,16 @@ def from_json( """ if isinstance(data, ThreadImpl): thread = data + # Invalidate caches derived from the previous binding so the + # rebind block below resolves them fresh against the new + # adapter/chat. Without this, `_state_adapter_instance` could + # continue pointing at the OLD chat's state backend and + # `_channel_cache` at the OLD adapter's channel — the thread + # would report the new adapter name but route state/channel + # operations to the previous context. + if adapter is not None or chat is not None: + thread._state_adapter_instance = None + thread._channel_cache = None else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index 9099717..f7e922e 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -693,6 +693,35 @@ def test_should_rebind_adapter_when_data_is_already_a_channelimpl(self): assert rebound.adapter.name == "teams" assert rebound.to_json()["adapterName"] == "teams" + def test_should_invalidate_state_cache_on_idempotent_rebind(self): + """When `from_json(existing_instance, adapter=X)` rebinds an already- + revived ChannelImpl, `_state_adapter_instance` must be invalidated + so subsequent `get_state`/`set_state` calls route through the new + binding, not the previous one. Regression for a Codex P1.""" + from chat_sdk.testing import create_mock_adapter as _create + from chat_sdk.testing import create_mock_state as _create_state + + first_adapter = _create("slack") + second_adapter = _create("teams") + first_state = _create_state() + + # Construct a channel with the first adapter + state explicitly + # (so the state cache is populated). + original = ChannelImpl( + _ChannelImplConfigWithAdapter( + id="C123", + adapter=first_adapter, + state_adapter=first_state, + ) + ) + assert original._state_adapter_instance is first_state + + # Rebind via the idempotent path. State cache must drop so the + # next access resolves against the new binding. + rebound = ChannelImpl.from_json(original, second_adapter) + assert rebound._state_adapter_instance is None + assert rebound.adapter.name == "teams" + # =========================================================================== # deriveChannelId (tested in channel.test.ts alongside ChannelImpl) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 29968c6..a2e71b8 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -604,6 +604,40 @@ def test_should_rebind_adapter_when_data_is_already_a_threadimpl(self, mock_stat assert rebound.adapter.name == "teams" assert rebound.to_json()["adapterName"] == "teams" + def test_should_invalidate_state_and_channel_caches_on_idempotent_rebind(self, mock_state): + """When `from_json(existing_instance, adapter=X)` rebinds an already- + revived ThreadImpl, caches derived from the previous binding must be + invalidated — otherwise `_state_adapter_instance` would continue + routing to the OLD chat's state backend and `_channel_cache` would + still reference the OLD adapter. Regression for a Codex P1.""" + from chat_sdk.testing import create_mock_adapter, create_mock_state + + first_adapter = create_mock_adapter("slack") + second_adapter = create_mock_adapter("teams") + first_state = create_mock_state() + + # Prime the thread with the first adapter + state, and force both + # caches (state and channel) to populate. + original = ThreadImpl( + _ThreadImplConfig( + id="slack:C123:1234.5678", + adapter=first_adapter, + state_adapter=first_state, + channel_id="C123", + ) + ) + _ = original.channel # populate _channel_cache + assert original._channel_cache is not None + assert original._state_adapter_instance is first_state + + # Rebind to a different adapter. Both caches must drop so the next + # access resolves against the new binding. + rebound = ThreadImpl.from_json(original, adapter=second_adapter) + assert rebound._channel_cache is None + assert rebound._state_adapter_instance is None + # And the adapter is actually rebound. + assert rebound.adapter.name == "teams" + def test_should_sync_adapter_name_when_explicit_adapter_is_bound(self, mock_state): """from_json(data, adapter=X) must update _adapter_name to X.name so to_json() doesn't serialize a stale name that refers to a different From a869df066e1f0eccfd59d6b786b0368f0d1da5fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 01:15:12 +0000 Subject: [PATCH 32/40] fix(serde): resolve adapter via _adapter.name on idempotent chat rebind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex found another variant of the cache/state rebinding class of bugs (the chat= path this time). If an existing ThreadImpl or ChannelImpl was constructed directly by a Chat instance, its `_adapter_name` is None — only `_adapter` is set. The idempotent chat-rebind path keyed the new-adapter lookup off `_adapter_name`: if thread._adapter_name: resolved = chat.get_adapter(thread._adapter_name) ... thread._state_adapter_instance = chat.get_state() With `_adapter_name == None`, the lookup was skipped but state was still reassigned to the new chat's backend. Result: post()/edit() continued routing through the OLD adapter while get_state()/set_state() hit the NEW chat — messages and state writes landing in different Chat contexts (and potentially different platforms or environments). Fix: in both ThreadImpl.from_json and ChannelImpl.from_json, fall back to the currently-bound adapter's `.name` when `_adapter_name` is empty, and record the name back onto the instance so future serialization/rebinds see it. 2 new regression tests exercise the direct-construction + chat- rebind path end-to-end (build two Chat instances each with a different `slack` adapter, construct a thread/channel through chat_a, rebind via from_json(chat=chat_b), assert both adapter AND state now point at chat_b's objects). 3461 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/channel.py | 14 +++++++++++--- src/chat_sdk/thread.py | 16 +++++++++++++--- tests/test_channel_faithful.py | 30 ++++++++++++++++++++++++++++++ tests/test_serialization.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 4a2d740..5e443b4 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -453,11 +453,19 @@ def from_json( # to_json() doesn't serialize a stale name. channel._adapter_name = adapter.name elif chat is not None: - if channel._adapter_name: - resolved = chat.get_adapter(channel._adapter_name) + # Resolve the adapter against the new Chat. `_adapter_name` may + # be None for channels constructed directly by a Chat instance, + # so fall back to the currently-bound adapter's name as the + # lookup key — without this, a chat rebind on a direct- + # constructed channel would update state routing but leave + # `_adapter` pointing at the OLD adapter (split-routing bug). + lookup_name = channel._adapter_name or (channel._adapter.name if channel._adapter is not None else None) + if lookup_name: + resolved = chat.get_adapter(lookup_name) if resolved is None: - raise RuntimeError(f'Adapter "{channel._adapter_name}" not found in the provided Chat instance') + raise RuntimeError(f'Adapter "{lookup_name}" not found in the provided Chat instance') channel._adapter = resolved + channel._adapter_name = lookup_name channel._state_adapter_instance = chat.get_state() elif has_chat_singleton() and channel._adapter_name: active = get_chat_singleton() diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 1ff0092..4b4e9bd 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -853,11 +853,21 @@ def from_json( # to_json() doesn't serialize a stale name after rebind. thread._adapter_name = adapter.name elif chat is not None: - if thread._adapter_name: - resolved = chat.get_adapter(thread._adapter_name) + # Resolve the adapter against the new Chat. `_adapter_name` may + # be None for threads constructed directly by a Chat instance + # (as opposed to dict-revived ones), so fall back to the + # currently-bound adapter's name as the lookup key. Without + # this, a chat rebind on a direct-constructed thread would + # update state routing but leave `_adapter` pointing at the + # OLD chat's adapter — a split-routing bug where `post()` and + # `set_state()` target different Chat contexts. + lookup_name = thread._adapter_name or (thread._adapter.name if thread._adapter is not None else None) + if lookup_name: + resolved = chat.get_adapter(lookup_name) if resolved is None: - raise RuntimeError(f'Adapter "{thread._adapter_name}" not found in the provided Chat instance') + raise RuntimeError(f'Adapter "{lookup_name}" not found in the provided Chat instance') thread._adapter = resolved + thread._adapter_name = lookup_name thread._state_adapter_instance = chat.get_state() elif has_chat_singleton() and thread._adapter_name: # Eagerly bind from the active/global chat so the thread doesn't diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index f7e922e..a15b482 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -722,6 +722,36 @@ def test_should_invalidate_state_cache_on_idempotent_rebind(self): assert rebound._state_adapter_instance is None assert rebound.adapter.name == "teams" + def test_should_rebind_adapter_on_idempotent_chat_rebind_for_direct_constructed_channel(self): + """When `from_json(existing_channel, chat=Y)` rebinds a channel that + was constructed directly (so `_adapter_name` is None), the new + chat's matching adapter must replace the old one. Otherwise state + calls route to chat Y while message operations still use the + previous adapter — split routing. Regression for a Codex P1.""" + from chat_sdk.chat import Chat, ChatConfig + from chat_sdk.testing import create_mock_adapter as _create + from chat_sdk.testing import create_mock_state as _create_state + + slack_a = _create("slack") + slack_b = _create("slack") + state_a = _create_state() + state_b = _create_state() + chat_b = Chat(ChatConfig(user_name="bot-b", adapters={"slack": slack_b}, state=state_b)) + + original = ChannelImpl( + _ChannelImplConfigWithAdapter( + id="C123", + adapter=slack_a, + state_adapter=state_a, + ) + ) + assert original._adapter is slack_a + assert original._adapter_name is None + + rebound = ChannelImpl.from_json(original, chat=chat_b) + assert rebound.adapter is slack_b, "adapter not rebound to the new chat" + assert rebound._state_adapter_instance is state_b, "state not rebound to the new chat" + # =========================================================================== # deriveChannelId (tested in channel.test.ts alongside ChannelImpl) diff --git a/tests/test_serialization.py b/tests/test_serialization.py index a2e71b8..fcaaab6 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -638,6 +638,39 @@ def test_should_invalidate_state_and_channel_caches_on_idempotent_rebind(self, m # And the adapter is actually rebound. assert rebound.adapter.name == "teams" + def test_should_rebind_adapter_on_idempotent_chat_rebind_for_direct_constructed_thread(self, mock_state): + """When `from_json(existing_instance, chat=Y)` rebinds a thread that + was originally constructed directly by a Chat (so `_adapter_name` + is None), the new chat's matching adapter must replace the old one. + Otherwise state calls go to chat Y while `post`/`edit` still use + chat X's adapter — split-routing. Regression for a Codex P1.""" + from chat_sdk.chat import Chat, ChatConfig + from chat_sdk.testing import create_mock_adapter, create_mock_state + + slack_a = create_mock_adapter("slack") + slack_b = create_mock_adapter("slack") + state_a = create_mock_state() + state_b = create_mock_state() + chat_b = Chat(ChatConfig(user_name="bot-b", adapters={"slack": slack_b}, state=state_b)) + + # Thread constructed via chat_a's path: _adapter is slack_a, + # _adapter_name is None (not set by direct construction). + original = ThreadImpl( + _ThreadImplConfig( + id="slack:C123:1234.5678", + adapter=slack_a, + state_adapter=state_a, + channel_id="C123", + ) + ) + assert original._adapter is slack_a + assert original._adapter_name is None + + # Rebind to chat_b. Both adapter and state must switch to chat_b's. + rebound = ThreadImpl.from_json(original, chat=chat_b) + assert rebound.adapter is slack_b, "adapter not rebound to the new chat" + assert rebound._state_adapter_instance is state_b, "state not rebound to the new chat" + def test_should_sync_adapter_name_when_explicit_adapter_is_bound(self, mock_state): """from_json(data, adapter=X) must update _adapter_name to X.name so to_json() doesn't serialize a stale name that refers to a different From 5daeb324aa547e2ecf9d73e826160d47dfe24a83 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 01:15:44 +0000 Subject: [PATCH 33/40] =?UTF-8?q?docs:=20add=207th=20principle=20=E2=80=94?= =?UTF-8?q?=20rebind/idempotent-path=20state=20coherence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Codex findings in a row landed on the same pattern: an idempotent factory path (from_json with an existing instance) that updated one binding but left stale caches or lookup keys from the previous binding. The symptom is always "split routing" — messages go to the old context, state to the new. This wasn't covered by any of the six existing principles (input sweeps, emit/parse symmetry, pass-interaction, sentinels, divergence budget, pre-ship). Pass-interaction comes closest but is about *pipeline* ordering, not *instance attribute* coherence across rebinds. Adding it as a distinct check means future idempotent APIs get a dedicated prompt: "walk every attribute and ask whether it came from the old binding." Also updates the CLAUDE.md summary to list the new check. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CLAUDE.md | 7 ++++--- docs/SELF_REVIEW.md | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b081cbe..f838b86 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,9 +69,10 @@ All thread IDs follow: `{adapter}:{channel}:{thread}` Before declaring any change ready, run the adversarial checks in [docs/SELF_REVIEW.md](docs/SELF_REVIEW.md). In short: input sweeps, emit/parse symmetry, pass-interaction, unforgeable sentinels, divergence -budget, and the pre-ship "what would an adversarial reviewer find?" -question. Apply these especially to novel logic, new regex/substitution -passes, and anything that lands as a divergence from upstream. +budget, rebind/state coherence, and the pre-ship "what would an +adversarial reviewer find?" question. Apply these especially to novel +logic, new regex/substitution passes, and anything that lands as a +divergence from upstream. ## Port Rules (TS → Python) diff --git a/docs/SELF_REVIEW.md b/docs/SELF_REVIEW.md index 8edb8a0..ac673a1 100644 --- a/docs/SELF_REVIEW.md +++ b/docs/SELF_REVIEW.md @@ -90,7 +90,26 @@ Every divergence must have: back to upstream's behavior - A CHANGELOG entry under "Python-specific (divergence from upstream)" -### 6. The pre-ship question +### 6. Rebind / idempotent-path state coherence + +When a constructor or factory accepts an already-constructed instance +and then "rebinds" or "updates" it (common in `from_json`-style +idempotent APIs, dependency-injection helpers, or adapter-swapping code), +every cached or derived piece of state must be re-resolved against the +new binding. Common leak paths: + +- Cached downstream instances (a `_channel_cache` on a thread, a state + adapter pointer, a resolved HTTP client). +- Cached lookup keys (a name copied from the old binding and not + re-synced to the new one). +- Lazy-resolution placeholders that were already resolved under the + old binding and now hold stale references. + +Walk every instance attribute and ask: "does this value come from the +previous binding, and would it route operations to the wrong context +after the rebind?" + +### 7. The pre-ship question Before declaring any change ready, ask yourself: From 5c4e999ee62b91b9c4cf98d7979f1c1ff0974a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 01:33:59 +0000 Subject: [PATCH 34/40] fix(serde, gchat): address 4 findings from 7-principle self-review re-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the full 7-principle discipline (docs/SELF_REVIEW.md) via 3 subagents on the novel code in this PR after adding principle #7. Caught 4 issues the previous rounds hadn't — the discipline is still paying off. [P2] Rebind leaks `_recent_messages` + `_message_history` (from_json subagent, principle #7) The last rebind-cache fix only cleared `_state_adapter_instance` and `_channel_cache`. `_recent_messages` (populated from the OLD adapter's fetches) and `_message_history` (the OLD chat's cache) were missed — both would keep routing to the previous context after a rebind. Adding them to the invalidation set on ThreadImpl (and `_message_history` on ChannelImpl). [P2] adapter= and chat= were not orthogonal (from_json subagent, principle #3) `from_json(t, adapter=A, chat=chat_b)` hit the `if adapter:` branch and the `elif chat:` branch was silently skipped — so `_adapter=A` was applied but `_state_adapter_instance` was left at None (invalidated by the cache clear) and lazy-resolved via the singleton on next access, NOT chat_b. Split routing. Made the two arguments orthogonal: the `chat` block always applies its state, and resolves an adapter via chat ONLY when `adapter` wasn't passed. Singleton fallback only fires when neither is passed. [P2] URLs containing `|` / `>` produce malformed `` emit (Codex review of c381a63 — matches a P3 the emit/parse symmetry subagent had flagged earlier) `foo:bar|baz` emitted as `` and parsed back with url=`foo:bar`. Added URL-delimiter check to the emit fallback set alongside the existing label check. [P3 — defensive] Orthogonal `adapter`/`chat` pair previously fell through an `elif adapter is None and has_chat_singleton() ...` singleton-fallback branch that was unreachable before; same branch now correctly gated so it only fires when neither arg was passed. 3 new regression tests (one per P2): - test_should_invalidate_recent_messages_and_message_history_on_rebind - test_should_set_state_from_chat_when_both_adapter_and_chat_passed - test_falls_back_to_parenthesized_form_for_urls_with_reserved_delimiters Deferred from the review (not fixed here): - Async generator hygiene when placeholder post_message raises before the try block — lazy generators don't actually leak. - Asymmetric try/except on final-content edit vs placeholder-clear edit — the two branches handle different content shapes (user stream content vs forced whitespace). Would consider if an adapter issue arose. - `_name` round-trip (upstream parity), `_is_subscribed_context` rebind (niche), KeyError on malformed dict (P3). 3464 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- .../adapters/google_chat/format_converter.py | 7 +- src/chat_sdk/channel.py | 43 ++++++------ src/chat_sdk/thread.py | 61 ++++++++++------- tests/test_gchat_format_extended.py | 25 +++++++ tests/test_serialization.py | 66 +++++++++++++++++++ 5 files changed, 157 insertions(+), 45 deletions(-) diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 6a43d0e..9ff156a 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -310,10 +310,15 @@ def _node_to_gchat(self, node: Content) -> str: # can't safely round-trip: # - labels containing `|`, `>`, `]`, or a newline would be # truncated on the platform or by our own parser; + # - URLs containing `|` or `>` would collide with the + # `` delimiters, re-parsing to a different URL + # than the one we intended; # - URLs without a scheme (e.g. relative paths like `/docs`) # won't match the reverse parser's scheme-prefixed regex, # so the link node would be lost on the read side. - if any(c in link_text for c in ("|", ">", "]", "\n")) or not _HAS_SCHEME_RE.match(url): + label_unsafe = any(c in link_text for c in ("|", ">", "]", "\n")) + url_unsafe = any(c in url for c in ("|", ">")) + if label_unsafe or url_unsafe or not _HAS_SCHEME_RE.match(url): return f"{link_text} ({url})" return f"<{url}|{link_text}>" diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 5e443b4..c527214 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -423,13 +423,14 @@ def from_json( """ if isinstance(data, ChannelImpl): channel = data - # Invalidate the state-adapter cache so the rebind block below - # resolves it fresh against the new adapter/chat. Without this, - # `get_state` / `set_state` calls would continue to route - # through the OLD chat's state backend even though the - # channel now reports the new adapter name. + # Invalidate caches derived from the previous binding so the + # rebind block below resolves them fresh against the new + # adapter/chat. Both `_state_adapter_instance` (old chat's + # state backend) and `_message_history` (old chat's cache) + # would route to the previous context otherwise. if adapter is not None or chat is not None: channel._state_adapter_instance = None + channel._message_history = None else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall @@ -446,28 +447,30 @@ def from_json( is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), ) ) + # `adapter` and `chat` are orthogonal: if both are passed we apply + # both (explicit adapter + chat's state). If only one is passed, + # the other's effects are left lazy. An earlier `elif chat` branch + # silently dropped chat's state when adapter was also passed, + # creating a split-routing bug. if adapter is not None: channel._adapter = adapter # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Keep _adapter_name in sync with the explicit adapter so # to_json() doesn't serialize a stale name. channel._adapter_name = adapter.name - elif chat is not None: - # Resolve the adapter against the new Chat. `_adapter_name` may - # be None for channels constructed directly by a Chat instance, - # so fall back to the currently-bound adapter's name as the - # lookup key — without this, a chat rebind on a direct- - # constructed channel would update state routing but leave - # `_adapter` pointing at the OLD adapter (split-routing bug). - lookup_name = channel._adapter_name or (channel._adapter.name if channel._adapter is not None else None) - if lookup_name: - resolved = chat.get_adapter(lookup_name) - if resolved is None: - raise RuntimeError(f'Adapter "{lookup_name}" not found in the provided Chat instance') - channel._adapter = resolved - channel._adapter_name = lookup_name + if chat is not None: + if adapter is None: + # Resolve adapter via chat using `_adapter_name` or the + # currently-bound adapter's name as the lookup key. + lookup_name = channel._adapter_name or (channel._adapter.name if channel._adapter is not None else None) + if lookup_name: + resolved = chat.get_adapter(lookup_name) + if resolved is None: + raise RuntimeError(f'Adapter "{lookup_name}" not found in the provided Chat instance') + channel._adapter = resolved + channel._adapter_name = lookup_name channel._state_adapter_instance = chat.get_state() - elif has_chat_singleton() and channel._adapter_name: + elif adapter is None and has_chat_singleton() and channel._adapter_name: active = get_chat_singleton() resolved = active.get_adapter(channel._adapter_name) if resolved is not None: diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 4b4e9bd..6735cc3 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -813,14 +813,22 @@ def from_json( thread = data # Invalidate caches derived from the previous binding so the # rebind block below resolves them fresh against the new - # adapter/chat. Without this, `_state_adapter_instance` could - # continue pointing at the OLD chat's state backend and - # `_channel_cache` at the OLD adapter's channel — the thread - # would report the new adapter name but route state/channel - # operations to the previous context. + # adapter/chat. Without this, attributes copied from the old + # binding would continue to route state/channel/message + # operations to the previous context — the classic split- + # routing bug. + # + # Every attribute that embeds a reference to the old context + # gets cleared here: `_state_adapter_instance` (old chat's + # state backend), `_channel_cache` (old adapter's channel), + # `_message_history` (old chat's history cache), and + # `_recent_messages` (old adapter's fetched content). The + # rebind block re-resolves each as needed. if adapter is not None or chat is not None: thread._state_adapter_instance = None thread._channel_cache = None + thread._message_history = None + thread._recent_messages = [] else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall @@ -846,32 +854,37 @@ def from_json( is_dm=data.get("isDM") if "isDM" in data else data.get("is_dm", False), ) ) + # `adapter` and `chat` are orthogonal: if both are passed we apply + # both (explicit adapter + chat's state). If only one is passed, + # the other's effects are left lazy. An earlier `elif chat` branch + # silently dropped chat's state when adapter was also passed, + # creating a split-routing bug. if adapter is not None: thread._adapter = adapter # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Keep _adapter_name in sync with the explicit adapter so # to_json() doesn't serialize a stale name after rebind. thread._adapter_name = adapter.name - elif chat is not None: - # Resolve the adapter against the new Chat. `_adapter_name` may - # be None for threads constructed directly by a Chat instance - # (as opposed to dict-revived ones), so fall back to the - # currently-bound adapter's name as the lookup key. Without - # this, a chat rebind on a direct-constructed thread would - # update state routing but leave `_adapter` pointing at the - # OLD chat's adapter — a split-routing bug where `post()` and - # `set_state()` target different Chat contexts. - lookup_name = thread._adapter_name or (thread._adapter.name if thread._adapter is not None else None) - if lookup_name: - resolved = chat.get_adapter(lookup_name) - if resolved is None: - raise RuntimeError(f'Adapter "{lookup_name}" not found in the provided Chat instance') - thread._adapter = resolved - thread._adapter_name = lookup_name + if chat is not None: + # If the caller didn't also pass an explicit adapter, resolve + # one from chat using the current name. Falls back to + # `_adapter.name` for direct-constructed threads where + # `_adapter_name` was never stored. + if adapter is None: + lookup_name = thread._adapter_name or (thread._adapter.name if thread._adapter is not None else None) + if lookup_name: + resolved = chat.get_adapter(lookup_name) + if resolved is None: + raise RuntimeError(f'Adapter "{lookup_name}" not found in the provided Chat instance') + thread._adapter = resolved + thread._adapter_name = lookup_name + # State always comes from chat when chat is explicitly passed. thread._state_adapter_instance = chat.get_state() - elif has_chat_singleton() and thread._adapter_name: - # Eagerly bind from the active/global chat so the thread doesn't - # lazily re-resolve later (which could hit a different chat). + elif adapter is None and has_chat_singleton() and thread._adapter_name: + # Singleton fallback only fires when neither adapter nor chat + # were explicitly passed. Eagerly bind from the active/global + # chat so the thread doesn't lazily re-resolve later (which + # could hit a different chat). active = get_chat_singleton() resolved = active.get_adapter(thread._adapter_name) if resolved is not None: diff --git a/tests/test_gchat_format_extended.py b/tests/test_gchat_format_extended.py index c24a836..caa6d4e 100644 --- a/tests/test_gchat_format_extended.py +++ b/tests/test_gchat_format_extended.py @@ -408,6 +408,31 @@ def test_emits_bare_url_when_link_label_is_empty(self): # `(https://example.com)` parenthetical either. assert converter.from_ast(ast) == "https://example.com" + def test_falls_back_to_parenthesized_form_for_urls_with_reserved_delimiters(self): + """URLs containing `|` or `>` can't be emitted in `` form + without aliasing with the syntax delimiters. Without the fallback, + `foo:bar|baz` → `` → parses as url=`foo:bar`, + label=`baz|Label`. Regression for a Codex P2.""" + converter = _converter() + for url in ["foo:bar|baz", "foo:bar>baz", "scheme:a|b>c"]: + ast = { + "type": "root", + "children": [ + { + "type": "paragraph", + "children": [ + { + "type": "link", + "url": url, + "children": [{"type": "text", "value": "Label"}], + } + ], + } + ], + } + result = converter.from_ast(ast) + assert result == f"Label ({url})", f"url={url!r}, got {result!r}" + def test_falls_back_to_parenthesized_form_for_urls_without_a_scheme(self): """URLs without an RFC 3986 scheme (relative paths like `/docs`, fragment-only anchors like `#section`, protocol-relative like diff --git a/tests/test_serialization.py b/tests/test_serialization.py index fcaaab6..d2e317d 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -638,6 +638,72 @@ def test_should_invalidate_state_and_channel_caches_on_idempotent_rebind(self, m # And the adapter is actually rebound. assert rebound.adapter.name == "teams" + def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, mock_state): + """Two additional caches that carry previous-binding references must + also drop on idempotent rebind: `_recent_messages` (populated from + adapter fetches) and `_message_history` (tied to chat's cache). + Regression for a self-review-subagent finding.""" + from chat_sdk.testing import create_mock_adapter, create_mock_state, create_test_message + + first_adapter = create_mock_adapter("slack") + second_adapter = create_mock_adapter("teams") + first_state = create_mock_state() + + # Prime caches: initial_message populates _recent_messages, and + # message_history is a sentinel cache object. + sentinel_history = object() + original = ThreadImpl( + _ThreadImplConfig( + id="slack:C123:1234.5678", + adapter=first_adapter, + state_adapter=first_state, + channel_id="C123", + initial_message=create_test_message("m1", "hello"), + message_history=sentinel_history, + ) + ) + assert len(original._recent_messages) == 1 + assert original._message_history is sentinel_history + + rebound = ThreadImpl.from_json(original, adapter=second_adapter) + assert rebound._recent_messages == [], "recent_messages should drop on rebind" + assert rebound._message_history is None, "message_history should drop on rebind" + + def test_should_set_state_from_chat_when_both_adapter_and_chat_passed(self, mock_state): + """adapter= and chat= are orthogonal. Passing both must apply both: + the explicit adapter is bound AND state comes from the explicit + chat. The previous `elif chat` branch silently ignored chat when + adapter was also passed, leading to split routing. Regression for + a self-review-subagent finding.""" + from chat_sdk.chat import Chat, ChatConfig + from chat_sdk.testing import create_mock_adapter, create_mock_state + + teams_adapter = create_mock_adapter("teams") + chat_state = create_mock_state() + chat_instance = Chat( + ChatConfig( + user_name="bot", + adapters={"slack": create_mock_adapter("slack")}, + state=chat_state, + ) + ) + + data = { + "_type": "chat:Thread", + "id": "slack:C123:1234.5678", + "channel_id": "C123", + "is_dm": False, + "adapter_name": "slack", + } + thread = ThreadImpl.from_json(data, adapter=teams_adapter, chat=chat_instance) + + # Explicit adapter wins for adapter binding. + assert thread.adapter is teams_adapter + # Explicit chat's state is applied (previously the elif branch + # skipped this, leaving state as None and silently routing to the + # singleton on next access). + assert thread._state_adapter_instance is chat_state + def test_should_rebind_adapter_on_idempotent_chat_rebind_for_direct_constructed_thread(self, mock_state): """When `from_json(existing_instance, chat=Y)` rebinds a thread that was originally constructed directly by a Chat (so `_adapter_name` From 2e141331e926f5c56fc19e85f00641d179ca8ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 01:43:19 +0000 Subject: [PATCH 35/40] fix+docs: 7-principle re-review + upstream drift audit round 2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ran the 7-principle discipline AGAIN with explicit upstream-TS drift checks. Three subagents, each scoped to one file, returned 9 findings between them. All addressed. [P1] thread._fallback_stream used `get_committable_text()` instead of `render()` in the edit loop and first-post path. `get_committable_text` is for append-only native-streaming surfaces (Slack native) — it holds back unclosed lines and wraps tables in code fences. The fallback path uses full-message replacement edits, so the correct method is `render()` (remend'd markdown, no append- only buffering). Upstream packages/chat/src/thread.ts:697 uses `render()`; we diverged silently. Two existing tests needed updating (the test for `["H", "i"]` with placeholder disabled previously asserted "one post of 'Hi', no edits" — a byproduct of the get_committable_text holdback. The upstream-correct behavior is "post 'H' immediately, edit to 'Hi'".) [P3] `_is_subscribed_context` not reset on idempotent rebind. A thread constructed inside a subscribed-context handler carries the flag; on rebind to a new adapter/chat, the new context has no active subscription, but `is_subscribed()` would still short- circuit to True. Added to the cache-invalidation list alongside the other stale-context attributes. [Docs] Five divergence rows added / extended in UPSTREAM_SYNC.md non-parity table (with matching code-site breadcrumbs): - Heading rendering as `*text*` (upstream plain-text) - Image rendering as `{alt} ({url})` (upstream drops URL) - Fallback streaming stream-exception capture + flush - Fallback streaming final `final_content` (remend'd) vs upstream `accumulated` (raw) - Extended existing `` label-fallback row to cover empty labels, schemeless URLs, and URLs containing `|` / `>` (all previously fixed but described in separate rows) Kept from round-2 reports as "debatable, defer": - Symmetry between to_ast (guards adjacent markers) and extract_plain_text (doesn't) — stripping is a best-effort fallback, not a round-trip contract. - Stand-alone reviver signature adaptation is already documented in reviver.py docstring. 1 new regression test for `_is_subscribed_context` rebind clearing. 2 existing tests updated to match upstream-correct render() behavior. 3465 tests pass, 0 pyrefly errors, ruff clean. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/UPSTREAM_SYNC.md | 6 +++- .../adapters/google_chat/format_converter.py | 10 +++++-- src/chat_sdk/thread.py | 30 +++++++++++++++---- tests/test_chat_faithful.py | 15 ++++++---- tests/test_serialization.py | 27 +++++++++++++++++ tests/test_thread_faithful.py | 18 +++++------ 6 files changed, 83 insertions(+), 23 deletions(-) diff --git a/docs/UPSTREAM_SYNC.md b/docs/UPSTREAM_SYNC.md index 7a2db66..9bda8ed 100644 --- a/docs/UPSTREAM_SYNC.md +++ b/docs/UPSTREAM_SYNC.md @@ -450,7 +450,11 @@ stay explicit instead of being rediscovered in code review. | Fallback streaming with whitespace-only streams | Placeholder cleared to `" "` on final edit | Placeholder left visible (`"..."` stuck) | Upstream 4.26 guards against empty edits but leaves the placeholder stranded on the message. We issue one final `edit_message(" ")` so the placeholder disappears when no real content was produced. | | Google Chat `` round-trip | `to_ast()` / `extract_plain_text()` parse the custom-label syntax back to a link node / bare label | `toAst()` / `extractPlainText()` leave `` as raw text (or parse the whole string as an autolink with a malformed URL) | Upstream 4.26 emits `` in `from_ast` but never taught the reverse direction to parse it. A message posted with `[label](url)` then read back through `fetch_messages` comes back as unstructured text (or worse, a link node with the full `url\|text` as its URL) in upstream. We close the round-trip via an AST placeholder substitution: each `` is extracted to a private-use sentinel, Markdown is parsed on the rest, and link nodes are injected where the sentinels landed. This avoids the Markdown parser's incomplete handling of balanced-parens link destinations, so URLs like `https://en.wikipedia.org/wiki/Foo_(bar)` round-trip intact. | | `from_json(data, adapter=X)` → `_adapter_name` | Updated to `X.name` so `to_json()` reflects the bound adapter | Kept at `json.adapterName`, so re-serialization can emit a name that no longer matches the actual adapter | Upstream TS has the same gap but only exposes it via the `fromJSON(json, adapter?)` overload. In Python we lean on this API more (explicit `chat=` / explicit `adapter=` is preferred over the singleton). We sync the name on rebind so runtime and serialize agree. | -| Google Chat link labels with `\|` / `>` / `]` / newline | Fall back to `text (url)` when the label contains reserved delimiters | Always ``, producing malformed output | Google Chat's `` has no escape for `\|` or `>`; `]` breaks our own `to_ast()` regex (which converts `` to Markdown `[text](url)`, and Markdown closes the label at the first `]`); newline breaks the single-line form. Upstream emits the malformed form regardless; we fall back to the pre-4.26 `text (url)` form in those narrow cases so the label stays intact and the URL still auto-links. | +| Google Chat link labels with `\|` / `>` / `]` / newline, empty labels, URLs without a scheme, or URLs containing `\|` / `>` | Fall back to `text (url)` (or bare URL for empty labels) when the `` form can't round-trip safely | Always ``, producing malformed or un-parseable output | Google Chat's `` has no escape for `\|` or `>`; `]` breaks our own `to_ast()` regex (which converts `` to Markdown `[text](url)`, and Markdown closes the label at the first `]`); newline breaks the single-line form; schemeless URLs and URLs containing `\|`/`>` don't match our reverse parser. Upstream emits the malformed form regardless; we fall back to the pre-4.26 `text (url)` form (or the bare URL for empty labels) so the label/URL stays intact and Google Chat's auto-link detection still fires for http(s). | +| Google Chat heading rendering | `#`-headings emit as `*text*` (bold) so they're visually distinct | Falls through to default node-to-text (plain concatenation) | Google Chat has no heading syntax; emitting plain text loses the visual hierarchy. Bold is the closest approximation the platform supports. | +| Google Chat image rendering | Images emit as `{alt} ({url})` or bare `url` | No image branch — falls through to default which concatenates children only, dropping the URL | Upstream silently drops image URLs when rendering to Google Chat text. We preserve the URL so the message content isn't lost. | +| Fallback streaming stream-exception capture | `_fallback_stream` captures exceptions from the stream iterator, flushes whatever content was already rendered, awaits `pending_edit`, and re-raises after cleanup | `try/finally` only — exception propagates immediately, `pendingEdit` is un-awaited, and the placeholder is stranded as `"..."` | Upstream leaves a hard UX failure when streams crash mid-flight (common: LLM connection drops): placeholder visible forever, orphan background task. We flush + clean up before re-raising so the caller still sees the original error and users see the partial content instead of a spinner. | +| Fallback streaming final SentMessage content | SentMessage + final edit carry `final_content` (remend'd — inline markers auto-closed) | SentMessage + final edit carry raw `accumulated` | Narrow UX refinement. If a stream ends with an unclosed `*`/`~~`/etc., upstream ships the unclosed marker; we run `_remend` so the user sees a clean final message. Not observable in the common case where streams close their own markers. | ### Platform-specific gaps diff --git a/src/chat_sdk/adapters/google_chat/format_converter.py b/src/chat_sdk/adapters/google_chat/format_converter.py index 9ff156a..bce584d 100644 --- a/src/chat_sdk/adapters/google_chat/format_converter.py +++ b/src/chat_sdk/adapters/google_chat/format_converter.py @@ -323,8 +323,10 @@ def _node_to_gchat(self, node: Content) -> str: return f"<{url}|{link_text}>" if node_type == "heading": - # Intentional improvement over TS SDK: Google Chat has no heading - # syntax, so we wrap headings in bold (*...*) for visual emphasis. + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. + # Google Chat has no heading syntax; upstream falls through to + # plain-text concatenation and loses the visual hierarchy. We + # wrap headings in bold (*...*) as the closest approximation. children = node.get("children", []) content = "".join(self._node_to_gchat(child) for child in children) return f"*{content}*" @@ -350,6 +352,10 @@ def _node_to_gchat(self, node: Content) -> str: return f"```\n{table_to_ascii(node)}\n```" if node_type == "image": + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. + # Upstream has no image branch; the default fallback concatenates + # children only, dropping the URL. We emit `{alt} ({url})` or the + # bare URL so the content survives. alt = node.get("alt", "") url = node.get("url", "") if alt: diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 6735cc3..7efa622 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -621,7 +621,14 @@ async def _edit_loop() -> None: pass # interval elapsed, do the edit if stop_event.is_set() or msg is None: break - content = renderer.get_committable_text() + # Use `render()` (not `get_committable_text`) for fallback + # streaming: the fallback path uses full-message replacement + # edits, so we want the current rendered state (including + # `_remend` inline-marker repair). `get_committable_text` is + # for append-only native-streaming surfaces and holds back + # content that may still change — wrong method for this + # code path. Matches upstream packages/chat/src/thread.ts. + content = renderer.render() if content.strip() and content != last_edit_content: try: await self.adapter.edit_message( @@ -642,13 +649,16 @@ async def _edit_loop() -> None: async for chunk in text_stream: renderer.push(chunk) if msg is None: - content = renderer.get_committable_text() + # `render()` for the first-post content too — same + # reasoning as the edit loop above. + content = renderer.render() if content.strip(): msg = await self.adapter.post_message(self._id, PostableMarkdown(markdown=content)) thread_id_for_edits = msg.thread_id or self._id last_edit_content = content pending_edit = asyncio.create_task(_edit_loop()) except Exception as exc: + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. # Capture so we can run the cleanup + flush below even when the # stream raises mid-iteration (e.g. LLM connection drops). The # alternative — letting the exception propagate immediately — @@ -694,6 +704,12 @@ async def _edit_loop() -> None: # Always ensure the final content is sent, regardless of what _edit_loop did. # Re-check last_edit_content after awaiting pending_edit since _edit_loop # may have updated it concurrently. + # + # Divergence from upstream — see docs/UPSTREAM_SYNC.md. + # Upstream ships `accumulated` (raw) for the final edit and the + # SentMessage; we ship `final_content` (remend'd, inline markers + # auto-closed). Narrow UX refinement — unobservable when streams + # close their own markers. if final_content.strip() and final_content != last_edit_content: await self.adapter.edit_message( thread_id_for_edits, @@ -821,14 +837,18 @@ def from_json( # Every attribute that embeds a reference to the old context # gets cleared here: `_state_adapter_instance` (old chat's # state backend), `_channel_cache` (old adapter's channel), - # `_message_history` (old chat's history cache), and - # `_recent_messages` (old adapter's fetched content). The - # rebind block re-resolves each as needed. + # `_message_history` (old chat's history cache), + # `_recent_messages` (old adapter's fetched content), and + # `_is_subscribed_context` (handler-context flag from the + # old chat — a thread rebound to a new context shouldn't + # claim it's still inside a subscribed handler). The rebind + # block re-resolves each as needed. if adapter is not None or chat is not None: thread._state_adapter_instance = None thread._channel_cache = None thread._message_history = None thread._recent_messages = [] + thread._is_subscribed_context = False else: # Explicit None-checks (not `or`) to avoid the truthiness trap: # `""` is a valid-but-falsy value that shouldn't silently fall diff --git a/tests/test_chat_faithful.py b/tests/test_chat_faithful.py index 57b01e9..bb24eae 100644 --- a/tests/test_chat_faithful.py +++ b/tests/test_chat_faithful.py @@ -344,13 +344,16 @@ async def _stream(): # No placeholder "..." should have been posted for _tid, content in adapter._post_calls: assert content != "..." - # With placeholder=None and no-newline chunks, the post-4.26 empty-content - # guard defers the first commit until stream end, so the final content - # arrives via post rather than an intermediate edit. + # With placeholder=None the first chunk posts immediately; subsequent + # chunks are delivered via edit_message (matches upstream TS's + # `render()`-based edit loop). assert len(adapter._post_calls) >= 1 - last_thread_id, last_content = adapter._post_calls[-1] - assert last_thread_id == "slack:C123:1234.5678" - last_markdown = last_content.markdown if hasattr(last_content, "markdown") else last_content + first_thread_id, _first_content = adapter._post_calls[0] + assert first_thread_id == "slack:C123:1234.5678" + # The final edit carries the full "Hi" payload. + assert len(adapter._edit_calls) >= 1 + last_edit_content = adapter._edit_calls[-1][2] + last_markdown = last_edit_content.markdown if hasattr(last_edit_content, "markdown") else last_edit_content assert last_markdown == "Hi" diff --git a/tests/test_serialization.py b/tests/test_serialization.py index d2e317d..700675c 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -638,6 +638,33 @@ def test_should_invalidate_state_and_channel_caches_on_idempotent_rebind(self, m # And the adapter is actually rebound. assert rebound.adapter.name == "teams" + def test_should_reset_is_subscribed_context_on_rebind(self, mock_state): + """A thread constructed inside a subscribed-context handler carries + `_is_subscribed_context=True`. If that thread is rebound to a new + adapter/chat, the new context has no active subscription — the flag + should clear so `is_subscribed()` doesn't short-circuit to True + against the new state backend. Regression for a self-review- + subagent finding.""" + from chat_sdk.testing import create_mock_adapter, create_mock_state + + first_adapter = create_mock_adapter("slack") + second_adapter = create_mock_adapter("teams") + first_state = create_mock_state() + + original = ThreadImpl( + _ThreadImplConfig( + id="slack:C123:1234.5678", + adapter=first_adapter, + state_adapter=first_state, + channel_id="C123", + is_subscribed_context=True, + ) + ) + assert original._is_subscribed_context is True + + rebound = ThreadImpl.from_json(original, adapter=second_adapter) + assert rebound._is_subscribed_context is False + def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, mock_state): """Two additional caches that carry previous-binding references must also drop on idempotent rebind: `_recent_messages` (populated from diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index cbf6f3b..45f79c4 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -386,16 +386,16 @@ async def test_should_support_disabling_the_placeholder_for_fallback_streaming(s # Should NOT have posted "..." placeholder_calls = [c for c in adapter._post_calls if c[1] == "..."] assert len(placeholder_calls) == 0 - # Final content is delivered through post since no mid-stream commit - # fired (no newline in the chunks) and the empty-content guard - # prevents an intermediate empty post. The whole exchange is - # exactly one post_message("Hi") and zero edits — any regression - # that splits it into an early post + late edit would fail here. + # With the placeholder disabled, the first chunk triggers the + # initial post; subsequent chunks accumulate and the edit loop + # flushes them via edit_message. Matches upstream TS behavior. assert len(adapter._post_calls) == 1 - assert len(adapter._edit_calls) == 0 - only_post = adapter._post_calls[0] - assert isinstance(only_post[1], PostableMarkdown) - assert only_post[1].markdown == "Hi" + first_post = adapter._post_calls[0] + assert isinstance(first_post[1], PostableMarkdown) + assert first_post[1].markdown == "H" + last_edit = adapter._edit_calls[-1] + assert isinstance(last_edit[2], PostableMarkdown) + assert last_edit[2].markdown == "Hi" # Python-specific regression: ensure whitespace-only streams don't leave # the placeholder stuck on the message. This is a deliberate divergence From c345ac3d8237362e61f8ead9296ead1fb161d86e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 02:15:01 +0000 Subject: [PATCH 36/40] fix(thread): SentMessage reflects actual platform content, not final_content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 on 2e14133: `_create_sent_message` was always invoked with `final_content` (from `renderer.finish()`) regardless of what actually ended up on the platform. Most visible in two cases: 1. Strict adapter (Telegram) rejects the placeholder-clear " " edit → platform still shows "..." → sent.text was "" (mismatch). 2. No-placeholder path posts `accumulated if accumulated.strip() else " "` → platform shows " " for whitespace streams → but `last_edit_content = accumulated` (possibly empty) didn't match the posted value either. Fix: track actual platform state via `last_edit_content` at every post/edit point, and thread that into the SentMessage instead of `final_content`. Concretely: - In the `msg is None` branch, set `last_edit_content = markdown` (the exact value posted), not `accumulated`. - After a successful final edit, set `last_edit_content = final_content`. - Use `last_edit_content` (not `final_content`) when constructing the SentMessage. Regression test pins the strict-adapter case — stranded placeholder with sent.text matching "...". The whitespace-only happy path still produces `sent.text == ""` because the markdown parser normalizes a lone space to empty text (unchanged from before; fixing that would require special-casing the parser and is a separate concern). 3466 tests pass, 0 pyrefly errors. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/thread.py | 13 +++++++++++-- tests/test_thread_faithful.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 7efa622..db9bc57 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -699,7 +699,9 @@ async def _edit_loop() -> None: markdown = accumulated if accumulated.strip() else " " msg = await self.adapter.post_message(self._id, PostableMarkdown(markdown=markdown)) thread_id_for_edits = msg.thread_id or self._id - last_edit_content = accumulated + # Track what was actually posted to the platform — not `accumulated`, + # which may differ (e.g. `" "` substituted for whitespace-only). + last_edit_content = markdown # Always ensure the final content is sent, regardless of what _edit_loop did. # Re-check last_edit_content after awaiting pending_edit since _edit_loop @@ -716,6 +718,7 @@ async def _edit_loop() -> None: msg.id, PostableMarkdown(markdown=final_content), ) + last_edit_content = final_content elif placeholder_text is not None and not final_content.strip() and last_edit_content == placeholder_text: # Divergence from upstream 4.26: upstream leaves the placeholder # visible when the stream produces only whitespace, which strands @@ -740,9 +743,15 @@ async def _edit_loop() -> None: exc, ) + # SentMessage reflects what's actually on the platform, not the + # renderer's last snapshot: in the placeholder-clear branch we + # edited to " " (or left "..." on a strict adapter); in the + # whitespace-only + no-placeholder branch we posted " " rather + # than the raw empty accumulated. Using `last_edit_content` + # ensures `sent.text` matches what a user would see. sent = self._create_sent_message( msg.id, - PostableMarkdown(markdown=final_content), + PostableMarkdown(markdown=last_edit_content), thread_id_for_edits, ) if self._message_history is not None: diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 45f79c4..5db6e1c 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -416,6 +416,39 @@ async def test_should_clear_placeholder_when_stream_is_whitespace_only(self): assert isinstance(final_edit[2], PostableMarkdown) assert final_edit[2].markdown == " " + # Codex P2 regression: the returned `SentMessage` must reflect what's + # actually visible on the platform, not the renderer's final_content. + # When the placeholder-clear edit fails (strict adapter rejects `" "`), + # the platform still shows "..." — `sent.text` must match that, not + # the empty `final_content` that `renderer.finish()` returned for a + # whitespace-only stream. Previously `_create_sent_message` used + # `final_content` unconditionally, so downstream consumers relying on + # `sent.text` saw "" while users on the platform saw "...". + @pytest.mark.asyncio + async def test_sent_message_reflects_stranded_placeholder_on_strict_adapter(self): + adapter = create_mock_adapter() + state = create_mock_state() + + async def strict_edit(thread_id: str, message_id: str, message: Any) -> RawMessage: + # Mirror Telegram: reject whitespace-only edits. + if isinstance(message, PostableMarkdown) and not message.markdown.strip(): + raise ValueError("Message text cannot be empty") + # Non-whitespace edits don't happen in this test; defensive no-op. + return await MockAdapter.edit_message(adapter, thread_id, message_id, message) # type: ignore[arg-type] + + adapter.edit_message = strict_edit # type: ignore[assignment] + + thread = _make_thread(adapter, state, logger=MockLogger()) # default placeholder "..." + text_stream = _create_text_stream([" ", "\n"]) + sent = await thread.post(text_stream) + + # Placeholder posted; the platform still shows "..." because the + # clear-to-" " edit was rejected. + assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") + + # The SentMessage must reflect that stranded "...", not "". + assert sent.text == "...", f"expected sent.text == '...', got {sent.text!r}" + # Python-specific regression: when a streaming source raises mid-stream # (e.g. LLM connection drops), `_fallback_stream` must still flush the # accumulated partial content + clean up the background edit task From 30f6ce54e02abd3bd32e34c6fc56b82accd16c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 02:28:36 +0000 Subject: [PATCH 37/40] fix(serde): make idempotent rebind transactional on adapter-lookup failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 (both ThreadImpl and ChannelImpl): `from_json(existing_inst, chat=Y)` was invalidating caches BEFORE calling `chat.get_adapter(name)`. If the lookup failed and raised `RuntimeError`, the caller catching the exception was left with a partially-mutated instance — `_state_adapter_ instance`, `_channel_cache`, `_message_history`, `_recent_messages`, `_is_subscribed_context` all silently wiped, but `_adapter` unchanged. Subsequent operations on the thread would re-resolve state/channel via the singleton instead of the previously-bound chat. Fix: pre-flight the adapter lookup BEFORE invalidating caches. If the name doesn't resolve in the new chat, raise immediately with all original state intact. The binding block below repeats the lookup when it actually applies the result; that's a cheap duplicate in exchange for transactional semantics. Same fix in both `ThreadImpl.from_json` and `ChannelImpl.from_json`. 2 regression tests, each captures a snapshot of every cache-bearing attribute before the rebind, calls `from_json(instance, chat=bad_chat)` expecting RuntimeError, then asserts every snapshotted attribute is byte-identical on the catch side: - test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails - test_should_leave_channel_unchanged_when_chat_rebind_lookup_fails 3468 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/channel.py | 25 ++++++++++++---- src/chat_sdk/thread.py | 18 +++++++++++ tests/test_channel_faithful.py | 42 ++++++++++++++++++++++++++ tests/test_serialization.py | 55 ++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index c527214..38ee207 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -423,11 +423,26 @@ def from_json( """ if isinstance(data, ChannelImpl): channel = data - # Invalidate caches derived from the previous binding so the - # rebind block below resolves them fresh against the new - # adapter/chat. Both `_state_adapter_instance` (old chat's - # state backend) and `_message_history` (old chat's cache) - # would route to the previous context otherwise. + # Transactional rebind: if chat= is passed without adapter=, + # we'll need to resolve the adapter from the new chat by name. + # Pre-flight that lookup and raise (leaving the channel + # unchanged) BEFORE invalidating caches, so a caller that + # catches the RuntimeError isn't left with a partially-reset + # instance. The binding block below repeats the lookup when + # it actually applies the result (cheap). + if chat is not None and adapter is None: + _lookup_name = channel._adapter_name or ( + channel._adapter.name if channel._adapter is not None else None + ) + if _lookup_name and chat.get_adapter(_lookup_name) is None: + raise RuntimeError( + f'Adapter "{_lookup_name}" not found in the provided Chat instance' + ) + + # Validation passed — safe to invalidate. + # Both `_state_adapter_instance` (old chat's state backend) + # and `_message_history` (old chat's cache) would route to + # the previous context otherwise. if adapter is not None or chat is not None: channel._state_adapter_instance = None channel._message_history = None diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index db9bc57..390a845 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -843,6 +843,24 @@ def from_json( # operations to the previous context — the classic split- # routing bug. # + # Transactional rebind: if the caller passes only `chat=`, we + # need to resolve the adapter from the new chat by name. If that + # lookup fails, we must raise BEFORE mutating any cache, so a + # caller that catches the RuntimeError is left with an + # unchanged thread. Pre-flight the lookup here; the binding + # block below repeats it (cheap dict access) when it actually + # applies the result. + if chat is not None and adapter is None: + _lookup_name = thread._adapter_name or ( + thread._adapter.name if thread._adapter is not None else None + ) + if _lookup_name and chat.get_adapter(_lookup_name) is None: + raise RuntimeError( + f'Adapter "{_lookup_name}" not found in the provided Chat instance' + ) + + # Validation passed — safe to invalidate caches. + # # Every attribute that embeds a reference to the old context # gets cleared here: `_state_adapter_instance` (old chat's # state backend), `_channel_cache` (old adapter's channel), diff --git a/tests/test_channel_faithful.py b/tests/test_channel_faithful.py index a15b482..ac74bec 100644 --- a/tests/test_channel_faithful.py +++ b/tests/test_channel_faithful.py @@ -722,6 +722,48 @@ def test_should_invalidate_state_cache_on_idempotent_rebind(self): assert rebound._state_adapter_instance is None assert rebound.adapter.name == "teams" + def test_should_leave_channel_unchanged_when_chat_rebind_lookup_fails(self): + """Transactional rebind: `from_json(existing_channel, chat=Y)` must + raise BEFORE mutating any cache when the adapter lookup against + the new chat fails. Callers catching the exception get their + original channel back unchanged. Regression for a Codex P2.""" + from chat_sdk.chat import Chat, ChatConfig + from chat_sdk.testing import create_mock_adapter as _create + from chat_sdk.testing import create_mock_state as _create_state + + slack_a = _create("slack") + state_a = _create_state() + chat_b = Chat( + ChatConfig( + user_name="bot-b", + adapters={"teams": _create("teams")}, # no 'slack' + state=_create_state(), + ) + ) + + original = ChannelImpl( + _ChannelImplConfigWithAdapter( + id="C123", + adapter=slack_a, + state_adapter=state_a, + ) + ) + snapshot = { + "_adapter": original._adapter, + "_adapter_name": original._adapter_name, + "_state_adapter_instance": original._state_adapter_instance, + "_message_history": original._message_history, + } + + with pytest.raises(RuntimeError, match='Adapter "slack" not found'): + ChannelImpl.from_json(original, chat=chat_b) + + for attr, before in snapshot.items(): + after = getattr(original, attr) + assert after == before, f"{attr}: expected {before!r}, got {after!r}" + assert original._adapter is snapshot["_adapter"] + assert original._state_adapter_instance is snapshot["_state_adapter_instance"] + def test_should_rebind_adapter_on_idempotent_chat_rebind_for_direct_constructed_channel(self): """When `from_json(existing_channel, chat=Y)` rebinds a channel that was constructed directly (so `_adapter_name` is None), the new diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 700675c..f48a46a 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -665,6 +665,61 @@ def test_should_reset_is_subscribed_context_on_rebind(self, mock_state): rebound = ThreadImpl.from_json(original, adapter=second_adapter) assert rebound._is_subscribed_context is False + def test_should_leave_thread_unchanged_when_chat_rebind_lookup_fails(self, mock_state): + """Transactional rebind: `from_json(existing_thread, chat=Y)` must + either fully apply the rebind or leave the thread untouched. If + `chat.get_adapter(name)` returns None, the RuntimeError must fire + BEFORE any cache invalidation — callers that catch the exception + should be able to keep using the thread with its original + bindings intact. Regression for a Codex P2.""" + from chat_sdk.chat import Chat, ChatConfig + from chat_sdk.testing import create_mock_adapter, create_mock_state, create_test_message + + slack_a = create_mock_adapter("slack") + state_a = create_mock_state() + # Chat B has a different adapter name, so looking up "slack" will fail. + chat_b = Chat( + ChatConfig( + user_name="bot-b", + adapters={"teams": create_mock_adapter("teams")}, + state=create_mock_state(), + ) + ) + + original = ThreadImpl( + _ThreadImplConfig( + id="slack:C123:1234.5678", + adapter=slack_a, + state_adapter=state_a, + channel_id="C123", + initial_message=create_test_message("m1", "hello"), + is_subscribed_context=True, + ) + ) + # Capture every state-bearing attribute before the rebind. + snapshot = { + "_adapter": original._adapter, + "_adapter_name": original._adapter_name, + "_state_adapter_instance": original._state_adapter_instance, + "_channel_cache": original._channel_cache, + "_message_history": original._message_history, + "_recent_messages": list(original._recent_messages), + "_is_subscribed_context": original._is_subscribed_context, + } + + with pytest.raises(RuntimeError, match='Adapter "slack" not found'): + ThreadImpl.from_json(original, chat=chat_b) + + # Every cached attribute must be exactly as it was before. A + # non-transactional implementation would have nulled some of these + # before the raise, making recovery unsafe. + for attr, before in snapshot.items(): + after = getattr(original, attr) + assert after == before, f"{attr}: expected {before!r}, got {after!r}" + # Extra identity check for objects where equality might be loose. + assert original._adapter is snapshot["_adapter"] + assert original._state_adapter_instance is snapshot["_state_adapter_instance"] + def test_should_invalidate_recent_messages_and_message_history_on_rebind(self, mock_state): """Two additional caches that carry previous-binding references must also drop on idempotent rebind: `_recent_messages` (populated from From 4e48d4c4dc8e08304e2c564cb3d3e774a8cb7e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 02:30:07 +0000 Subject: [PATCH 38/40] =?UTF-8?q?chore:=20ruff=20format=20+=20extend=20SEL?= =?UTF-8?q?F=5FREVIEW=20=C2=A76=20with=20error-path=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things in one commit since they're both cleanup from the transactional-rebind fix in 30f6ce5: - ruff format: 30f6ce5's manual edits to thread.py / channel.py introduced formatting drift that CI caught. No semantic change. - docs/SELF_REVIEW.md §6 (Rebind / idempotent-path state coherence) now explicitly covers error paths: "order the steps so any raise leaves the object in a well-defined state." Codex found a variant of principle #7 that the round-3 subagent's attribute walk had missed — the subagent checked WHICH attributes are invalidated on rebind, not WHEN they are invalidated relative to raises. The §6 note names this subtlety so future reviews catch it. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- docs/SELF_REVIEW.md | 8 ++++++++ src/chat_sdk/channel.py | 4 +--- src/chat_sdk/thread.py | 8 ++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/SELF_REVIEW.md b/docs/SELF_REVIEW.md index ac673a1..d479c53 100644 --- a/docs/SELF_REVIEW.md +++ b/docs/SELF_REVIEW.md @@ -109,6 +109,14 @@ Walk every instance attribute and ask: "does this value come from the previous binding, and would it route operations to the wrong context after the rebind?" +**Also walk the error paths.** If the rebind can raise partway through +(e.g. an adapter lookup returns None, a network call fails), order the +steps so any raise leaves the object in a well-defined state — either +unchanged (transactional) or fully committed to the new binding. +Validating inputs before mutating is the simplest way; try/except with +rollback is the alternative. A half-mutated object that the caller +catches-and-reuses is the worst outcome. + ### 7. The pre-ship question Before declaring any change ready, ask yourself: diff --git a/src/chat_sdk/channel.py b/src/chat_sdk/channel.py index 38ee207..2981137 100644 --- a/src/chat_sdk/channel.py +++ b/src/chat_sdk/channel.py @@ -435,9 +435,7 @@ def from_json( channel._adapter.name if channel._adapter is not None else None ) if _lookup_name and chat.get_adapter(_lookup_name) is None: - raise RuntimeError( - f'Adapter "{_lookup_name}" not found in the provided Chat instance' - ) + raise RuntimeError(f'Adapter "{_lookup_name}" not found in the provided Chat instance') # Validation passed — safe to invalidate. # Both `_state_adapter_instance` (old chat's state backend) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 390a845..09fbb6f 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -851,13 +851,9 @@ def from_json( # block below repeats it (cheap dict access) when it actually # applies the result. if chat is not None and adapter is None: - _lookup_name = thread._adapter_name or ( - thread._adapter.name if thread._adapter is not None else None - ) + _lookup_name = thread._adapter_name or (thread._adapter.name if thread._adapter is not None else None) if _lookup_name and chat.get_adapter(_lookup_name) is None: - raise RuntimeError( - f'Adapter "{_lookup_name}" not found in the provided Chat instance' - ) + raise RuntimeError(f'Adapter "{_lookup_name}" not found in the provided Chat instance') # Validation passed — safe to invalidate caches. # From 108914e94b18fae5c1712f16478a7c548c738ab7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 04:41:46 +0000 Subject: [PATCH 39/40] fix(thread): wrap final-edit in try/except to avoid orphaning placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 subagent P2: the sibling placeholder-clear edit was wrapped defensively in try/except (to handle Telegram-style whitespace rejection), but the primary final-content edit wasn't. If the final edit raises (rate-limit, transient 5xx, content-policy reject), the exception propagates out of `_fallback_stream` and the caller never gets a SentMessage — even though the placeholder message is already sitting on the platform. The caller catching the exception has no handle to edit/delete it, and the message is orphaned. Symmetric fix with the placeholder-clear branch: try the edit, log on failure, leave `last_edit_content` alone. The SentMessage returned to the caller reflects whatever was last successfully on the platform (via `last_edit_content`) and carries a valid message ID the caller can still act on. Regression test injects an always-failing `edit_message`, asserts (a) thread.post() doesn't raise, (b) sent.text matches the placeholder "..." that's actually on the platform, (c) the expected warn log fires. 3469 tests pass. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- src/chat_sdk/thread.py | 27 ++++++++++++++---- tests/test_thread_faithful.py | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/chat_sdk/thread.py b/src/chat_sdk/thread.py index 09fbb6f..461d344 100644 --- a/src/chat_sdk/thread.py +++ b/src/chat_sdk/thread.py @@ -713,12 +713,27 @@ async def _edit_loop() -> None: # auto-closed). Narrow UX refinement — unobservable when streams # close their own markers. if final_content.strip() and final_content != last_edit_content: - await self.adapter.edit_message( - thread_id_for_edits, - msg.id, - PostableMarkdown(markdown=final_content), - ) - last_edit_content = final_content + # Symmetric with the placeholder-clear branch below: if the + # adapter rejects the final edit (rate-limit, transient 5xx, + # content-policy violation), log and leave the message as + # whatever was last successfully edited. The caller still + # receives a SentMessage that matches `last_edit_content` + # and can call `.edit()`/`.delete()` on it — without this + # try/except, propagating the exception orphans the posted + # placeholder message on the platform. + try: + await self.adapter.edit_message( + thread_id_for_edits, + msg.id, + PostableMarkdown(markdown=final_content), + ) + last_edit_content = final_content + except Exception as exc: + if self._logger: + self._logger.warn( + "fallbackStream final edit failed; message reflects previous content", + exc, + ) elif placeholder_text is not None and not final_content.strip() and last_edit_content == placeholder_text: # Divergence from upstream 4.26: upstream leaves the placeholder # visible when the stream produces only whitespace, which strands diff --git a/tests/test_thread_faithful.py b/tests/test_thread_faithful.py index 5db6e1c..533daac 100644 --- a/tests/test_thread_faithful.py +++ b/tests/test_thread_faithful.py @@ -416,6 +416,59 @@ async def test_should_clear_placeholder_when_stream_is_whitespace_only(self): assert isinstance(final_edit[2], PostableMarkdown) assert final_edit[2].markdown == " " + # Symmetric with the placeholder-clear defensive try/except: if the + # final-content edit fails (rate-limit, 5xx, content-policy reject), + # `_fallback_stream` must still return a SentMessage for the posted + # placeholder so the caller has a handle to edit/delete it. Without + # this, the exception propagates and the placeholder message on the + # platform is orphaned — the caller catching the error has no way to + # reach it. + @pytest.mark.asyncio + async def test_should_swallow_final_edit_error_and_return_sent_message(self): + adapter = create_mock_adapter() + state = create_mock_state() + logger = MockLogger() + + # Reject every edit_message call with real content — simulating + # e.g. a permanent 5xx on the final flush. + edit_error = RuntimeError("adapter rejected final edit") + original_edit = adapter.edit_message + rejected: list[PostableMarkdown] = [] + + async def always_fail_edit(thread_id: str, message_id: str, message: Any) -> RawMessage: + if isinstance(message, PostableMarkdown) and message.markdown.strip(): + rejected.append(message) + raise edit_error + return await original_edit(thread_id, message_id, message) + + adapter.edit_message = always_fail_edit # type: ignore[assignment] + + thread = _make_thread(adapter, state, streaming_update_interval_ms=10, logger=logger) + + async def stream() -> AsyncIterator[str]: + yield "Hello\n" + await asyncio.sleep(0.05) + yield "world\n" + + # Must not raise — the caller gets a SentMessage back even though + # the final edit was rejected. + sent = await thread.post(stream()) + + # Placeholder was posted; at least one edit was attempted and + # rejected; the failure was logged. + assert adapter._post_calls[0] == ("slack:C123:1234.5678", "...") + assert len(rejected) >= 1 + assert any( + call[0] == "fallbackStream final edit failed; message reflects previous content" + for call in logger.warn.calls + ), [c[0] for c in logger.warn.calls] + + # SentMessage is valid and reflects the placeholder (what's actually + # on the platform, since every edit was rejected). + assert sent is not None + assert sent.id == "msg-1" + assert sent.text == "..." + # Codex P2 regression: the returned `SentMessage` must reflect what's # actually visible on the platform, not the renderer's final_content. # When the placeholder-clear edit fails (strict adapter rejects `" "`), From 40ba76805a3d8553b5050a5af7ab75c6ff168878 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 04:44:54 +0000 Subject: [PATCH 40/40] docs(changelog): backfill 4 Python-specific divergences from non-parity table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-5 subagent found the one remaining gap: `docs/SELF_REVIEW.md` §5 requires a CHANGELOG entry for every divergence, and the non-parity table has 8 Python-specific rows while the CHANGELOG had only 4 bullets. Backfill the missing four and broaden one existing bullet: - Extended the `` reserved-delimiters row to cover `]`, newlines, empty labels, schemeless URLs, and URLs containing `|`/`>` — all handled by the same emit-side fallback check, all accumulated over several commits during review iteration but only partially reflected in the CHANGELOG. - Added dedicated bullets for Google Chat heading → bold rendering (upstream loses visual hierarchy by falling through to plain text). - Added dedicated bullet for Google Chat image rendering (upstream silently drops the URL). - Added bullet for fallback streaming's stream-exception capture + flush before re-raise (upstream propagates immediately, orphaning pendingEdit and stranding the placeholder). - Added bullet for fallback streaming's `remend`'d final SentMessage content vs upstream's raw `accumulated`. No code changes. Round-5 verdict on code: clean across all 7 principles. Only doc bookkeeping was missing. https://claude.ai/code/session_01XE1bMoQ5BjCvgi1iLGKKhG --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c8bf8..e236453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,11 @@ Synced to [Vercel Chat 4.26.0](https://github.com/vercel/chat). - **Fallback streaming clears stranded placeholders**: when a stream produces only whitespace with the default placeholder enabled, the final edit replaces `"..."` with `" "` so the message doesn't render as permanently loading. Upstream 4.26 intentionally leaves the placeholder visible to avoid empty-edit API calls; we issue one final edit to `" "` instead. Documented under [Known Non-Parity](docs/UPSTREAM_SYNC.md#known-non-parity-with-typescript-sdk). - **Google Chat `` round-trip**: upstream 4.26 emits Google Chat's custom-label link syntax in the outgoing direction but doesn't parse it back in `to_ast()` / `extract_plain_text()`. A `[label](url)` posted through the gchat adapter would round-trip back as raw `""` text with no link node, breaking downstream handlers. We added the inverse regex to close the round-trip. Documented under Known Non-Parity. - **`from_json(data, adapter=X)` syncs `_adapter_name`**: upstream leaves `_adapterName` at the payload value even when an explicit adapter is bound, so `to_json()` can emit a stale name that refers to a different adapter than what runtime calls use. We update `_adapter_name = adapter.name` on explicit rebind so serialize and runtime stay consistent. Documented under Known Non-Parity. -- **Google Chat link labels with `|` / `>` / newline**: Google Chat's `` syntax has no escape for `|` or `>`. Upstream emits ` b>` verbatim, which truncates the label on the platform and can't round-trip back. We fall back to the pre-4.26 `text (url)` form whenever the label contains one of those reserved characters — the URL is still auto-linked by the platform and the label stays intact. Documented under Known Non-Parity. +- **Google Chat `` emit falls back to `text (url)` when it can't round-trip**: the custom-label syntax is only safe when the label doesn't contain `|` / `>` / `]` / newline, the label is non-empty, and the URL has an RFC 3986 scheme and no `|` or `>`. Upstream unconditionally emits ``, producing malformed output for the edge cases. We fall back to `text (url)` (or bare URL for empty labels) so the content survives the round-trip and Google Chat's auto-link detection still fires for http(s) URLs. Documented under Known Non-Parity. +- **Google Chat headings render as bold**: `#` / `##` / etc. emit as `*text*` for visual distinction. Upstream falls through to plain-text concatenation and loses the visual hierarchy entirely. Google Chat has no heading syntax, and bold is the closest approximation the platform supports. Documented under Known Non-Parity. +- **Google Chat images render as `{alt} ({url})` (or bare URL)**: upstream has no image branch — the default fallback concatenates children only and silently drops the URL. We preserve the URL so the content isn't lost. Documented under Known Non-Parity. +- **Fallback streaming captures stream exceptions and flushes before re-raising**: if the text stream iterator raises mid-flight (e.g. LLM connection drops), `_fallback_stream` now awaits `pending_edit`, flushes whatever partial content was rendered, clears the placeholder if appropriate, and THEN re-raises the original exception. Upstream propagates immediately, orphaning `pendingEdit` as a background task and stranding `"..."` on the message. Documented under Known Non-Parity. +- **Fallback streaming final SentMessage carries repaired markdown**: the returned `SentMessage.markdown` is `renderer.finish()` output (`_remend`'d — inline markers auto-closed). Upstream ships raw `accumulated`. Narrow UX refinement — unobservable unless the stream ends mid-marker. Documented under Known Non-Parity. ## 0.4.25 (2026-04-10)