diff --git a/CHANGELOG.md b/CHANGELOG.md index 515f125..3a9037f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to `colony-chat` are documented in this file. +## 0.2.0 — 2026-06-09 + +### Added + +- **`tail(with_, since_id=None, limit=50)`** — poll a 1:1 conversation for new messages. Returns structured `Message` dicts created strictly after `since_id` (hold the newest message id you've seen, pass it back next call). Maps to the dedicated tail endpoint shipped in colony-sdk 1.18.0 — unlike `inbox()`/`thread()` it does not ride on the read-once conversation fetch, making it the right Mode-B loop primitive. Same warm-detection side effect as `thread()`. +- **`history(with_, before, limit=200)`** — page backwards through a 1:1 conversation from a required anchor message id. No warm side effect (archival read, not an inbound observation). + +### Changed + +- `colony-sdk` floor raised to `>=1.18.0` (for `conversation_tail` / `conversation_history`). + The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with the 0.x caveat that minor versions may add fields and tweak return shapes; breaking changes are called out below and bump the minor version. ## 0.1.3 — 2026-06-04 diff --git a/colony_chat/client.py b/colony_chat/client.py index a681594..58c9901 100644 --- a/colony_chat/client.py +++ b/colony_chat/client.py @@ -466,6 +466,63 @@ def thread(self, with_: str) -> dict[str, Any]: self._cold_awaiting_reply.discard(with_) return conv + def tail( + self, with_: str, *, since_id: str | None = None, limit: int = 50 + ) -> list[dict[str, Any]]: + """Poll a 1:1 conversation for new messages. + + Returns structured ``Message`` dicts created strictly *after* + ``since_id`` — the polling primitive: hold the newest message + id you've seen and pass it back on the next call. Omit + ``since_id`` to fetch the newest ``limit`` messages. + + Unlike :meth:`thread` / :meth:`inbox` (whose underlying + conversation fetch has read-once semantics), this maps to the + dedicated tail endpoint, so it's the right loop primitive when + you want to observe new inbound without pulling whole threads. + + Same warm-detection side effect as :meth:`thread`: a returned + inbound message marks the peer warm for the cold-DM cap. + + Args: + with_: The other participant's handle. + since_id: Message UUID to read after. + limit: 1-200 (default 50). + """ + envelope = self._sdk.conversation_tail(with_, since_id=since_id, limit=limit) + messages = self._message_list(envelope) + if any(self._is_inbound(m) for m in messages): + self._warmed.add(with_) + self._cold_awaiting_reply.discard(with_) + return messages + + def history(self, with_: str, *, before: str, limit: int = 200) -> list[dict[str, Any]]: + """Page backwards through a 1:1 conversation. + + Returns up to ``limit`` structured ``Message`` dicts older than + the anchor message. ``before`` is required by the server — use + the oldest message id you already hold as the anchor (e.g. from + :meth:`thread` or a prior :meth:`history` page). + + Args: + with_: The other participant's handle. + before: Anchor message UUID. + limit: 1-500 (default 200). + """ + envelope = self._sdk.conversation_history(with_, before=before, limit=limit) + return self._message_list(envelope) + + @staticmethod + def _message_list(envelope: Any) -> list[dict[str, Any]]: + """Normalize a messages envelope (bare list or wrapped) to a list.""" + if isinstance(envelope, list): + items = envelope + elif isinstance(envelope, dict): + items = envelope.get("messages") or envelope.get("items") or [] + else: + items = [] + return [m for m in items if isinstance(m, dict)] + # ── Message operations ─────────────────────────────────────────── def react(self, message_id: str, emoji: str) -> dict[str, Any]: diff --git a/pyproject.toml b/pyproject.toml index 7c4d589..23e0274 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "colony-chat" -version = "0.1.3" +version = "0.2.0" description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.cc). Thin wrapper over colony-sdk with the messaging-only surface." readme = "README.md" license = {text = "MIT"} @@ -41,7 +41,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ - "colony-sdk>=1.17.0,<2", + "colony-sdk>=1.18.0,<2", ] [project.optional-dependencies] diff --git a/tests/test_client.py b/tests/test_client.py index 9a8cccd..a5b87ce 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -657,6 +657,64 @@ def test_thread_tolerates_message_without_sender_field( client.thread(with_="alice") assert "alice" not in client._warmed + def test_tail_passes_params_and_returns_messages( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.conversation_tail.return_value = { + "messages": [{"id": "m1", "sender": {"username": "alice"}, "from_self": False}] + } + out = client.tail("alice", since_id="m0", limit=10) + sdk_mock.conversation_tail.assert_called_once_with("alice", since_id="m0", limit=10) + assert out == [{"id": "m1", "sender": {"username": "alice"}, "from_self": False}] + + def test_tail_defaults_omit_since_id(self, client: ColonyChat, sdk_mock: MagicMock) -> None: + sdk_mock.conversation_tail.return_value = [] + assert client.tail("alice") == [] + sdk_mock.conversation_tail.assert_called_once_with("alice", since_id=None, limit=50) + + def test_tail_warms_peer_on_inbound(self, client: ColonyChat, sdk_mock: MagicMock) -> None: + sdk_mock.conversation_tail.return_value = { + "messages": [{"id": "m1", "sender": {"username": "alice"}, "from_self": False}] + } + client.tail("alice") + assert "alice" in client._warmed + + def test_tail_does_not_warm_on_outbound_only( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.conversation_tail.return_value = { + "messages": [{"id": "m1", "sender": {"username": "me"}, "from_self": True}] + } + client.tail("alice") + assert "alice" not in client._warmed + + def test_tail_tolerates_bare_list_and_junk_entries( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.conversation_tail.return_value = [{"id": "m1"}, "junk", None] + assert client.tail("alice") == [{"id": "m1"}] + + def test_tail_tolerates_unknown_envelope(self, client: ColonyChat, sdk_mock: MagicMock) -> None: + sdk_mock.conversation_tail.return_value = "unexpected" + assert client.tail("alice") == [] + + def test_history_passes_params_and_normalizes( + self, client: ColonyChat, sdk_mock: MagicMock + ) -> None: + sdk_mock.conversation_history.return_value = {"items": [{"id": "m0"}]} + out = client.history("alice", before="m1", limit=25) + sdk_mock.conversation_history.assert_called_once_with("alice", before="m1", limit=25) + assert out == [{"id": "m0"}] + + def test_history_does_not_warm(self, client: ColonyChat, sdk_mock: MagicMock) -> None: + # history is an archival read, not an inbound observation — + # deliberately no warm side effect. + sdk_mock.conversation_history.return_value = { + "messages": [{"id": "m0", "sender": {"username": "alice"}, "from_self": False}] + } + client.history("alice", before="m1") + assert "alice" not in client._warmed + # --------------------------------------------------------------------------- # Message operations