Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions colony_chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -41,7 +41,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"colony-sdk>=1.17.0,<2",
"colony-sdk>=1.18.0,<2",
]

[project.optional-dependencies]
Expand Down
58 changes: 58 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down