From d1fba584aade33d00ed52a264172464ded0dd94b Mon Sep 17 00:00:00 2001 From: lda Date: Thu, 24 Sep 2026 04:51:32 +0700 Subject: [PATCH] feat: add thread info and status reads --- src/agentmsgs/__init__.py | 12 +++++- src/agentmsgs/core/__init__.py | 4 +- src/agentmsgs/core/app.py | 10 ++++- src/agentmsgs/core/ops.py | 12 +++++- src/agentmsgs/core/store.py | 21 +++++++++- src/agentmsgs/core/types.py | 16 ++++++++ src/agentmsgs/mcp/server.py | 60 ++++++++++++++++++++++++++++ src/agentmsgs/stores/memory.py | 31 ++++++++++++++- src/agentmsgs/stores/sqlite.py | 69 +++++++++++++++++++++++++++++++- tests/test_app.py | 19 +++++++++ tests/test_mcp.py | 51 ++++++++++++++++++++++++ tests/test_ops.py | 18 +++++++++ tests/test_store_memory.py | 50 +++++++++++++++++++++++ tests/test_store_protocol.py | 57 ++++++++++++++++----------- tests/test_store_sqlite.py | 72 ++++++++++++++++++++++++++++++++++ tests/test_types.py | 27 ++++++++++++- 16 files changed, 496 insertions(+), 33 deletions(-) create mode 100644 tests/test_mcp.py diff --git a/src/agentmsgs/__init__.py b/src/agentmsgs/__init__.py index 06587ab..834f90d 100644 --- a/src/agentmsgs/__init__.py +++ b/src/agentmsgs/__init__.py @@ -1,7 +1,15 @@ from .core.app import App -from .core.types import Agent, Message, Thread +from .core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState -__all__ = ["Agent", "App", "Message", "Thread", "main"] +__all__ = [ + "Agent", + "App", + "Message", + "Thread", + "ThreadInfo", + "ThreadReadState", + "main", +] def main() -> None: diff --git a/src/agentmsgs/core/__init__.py b/src/agentmsgs/core/__init__.py index 861e4d9..892c01a 100644 --- a/src/agentmsgs/core/__init__.py +++ b/src/agentmsgs/core/__init__.py @@ -1,4 +1,4 @@ from .app import App -from .types import Agent, Message, Thread +from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState -__all__ = ["Agent", "App", "Message", "Thread"] +__all__ = ["Agent", "App", "Message", "Thread", "ThreadInfo", "ThreadReadState"] diff --git a/src/agentmsgs/core/app.py b/src/agentmsgs/core/app.py index f29ab9a..265da4a 100644 --- a/src/agentmsgs/core/app.py +++ b/src/agentmsgs/core/app.py @@ -7,7 +7,7 @@ from agentmsgs.stores.memory import InMemoryStore from . import ops from .store import Store -from .types import Agent, Thread +from .types import Agent, Thread, ThreadInfo, ThreadReadState @dataclass @@ -29,6 +29,14 @@ class App: def get_thread(self, id: uuid.UUID) -> Thread | None: return self.store.get_thread(id) + def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None: + return ops.get_thread_info(self.store, id) + + def get_thread_read_state( + self, id: uuid.UUID, agent: Agent + ) -> ThreadReadState | None: + return ops.get_thread_read_state(self.store, id, agent) + def find_threads(self, *agents: Agent) -> list[Thread]: return ops.find_threads(self.store, set(agents)) diff --git a/src/agentmsgs/core/ops.py b/src/agentmsgs/core/ops.py index ed8cc08..0a1cea2 100644 --- a/src/agentmsgs/core/ops.py +++ b/src/agentmsgs/core/ops.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from .store import Store -from .types import Agent, Message, Thread +from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState def get_or_create_agent(store: Store, name: str) -> Agent: @@ -28,6 +28,16 @@ def find_threads(store: Store, containing: set[Agent]) -> list[Thread]: return store.find_threads(containing) +def get_thread_info(store: Store, tid: uuid.UUID) -> ThreadInfo | None: + return store.get_thread_info(tid) + + +def get_thread_read_state( + store: Store, tid: uuid.UUID, agent: Agent +) -> ThreadReadState | None: + return store.get_thread_read_state(tid, agent) + + def join_thread(store: Store, tid: uuid.UUID, agent: Agent) -> Thread: return store.add_participant(tid, agent) diff --git a/src/agentmsgs/core/store.py b/src/agentmsgs/core/store.py index 129db01..c35d62d 100644 --- a/src/agentmsgs/core/store.py +++ b/src/agentmsgs/core/store.py @@ -3,27 +3,44 @@ from __future__ import annotations import uuid from typing import Protocol -from .types import Agent, Message, Thread +from .types import Agent, Message, Thread, ThreadInfo, ThreadReadState -class Store(Protocol): +class AgentStore(Protocol): def get_or_create_agent(self, name: str) -> Agent: ... def get_agent_by_name(self, name: str) -> Agent | None: ... def get_agent_by_id(self, id: uuid.UUID) -> Agent | None: ... def create_agent(self, name: str) -> Agent: ... def delete_agent(self, id: uuid.UUID) -> None: ... def list_agents(self) -> list[Agent]: ... + + +class ThreadStore(Protocol): def create_thread(self, participants: set[Agent]) -> Thread: ... def get_thread(self, id: uuid.UUID) -> Thread | None: ... def find_threads(self, containing: set[Agent]) -> list[Thread]: ... + def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None: ... + def get_thread_read_state( + self, id: uuid.UUID, agent: Agent + ) -> ThreadReadState | None: ... def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ... def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: ... def delete_thread(self, id: uuid.UUID) -> None: ... + + +class MessageStore(Protocol): def append_message( self, thread_id: uuid.UUID, sender: Agent, content: str ) -> Message: ... def list_messages( self, thread_id: uuid.UUID, after_seq: int = 0 ) -> list[Message]: ... + + +class CursorStore(Protocol): def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ... def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ... + + +class Store(AgentStore, ThreadStore, MessageStore, CursorStore, Protocol): + pass diff --git a/src/agentmsgs/core/types.py b/src/agentmsgs/core/types.py index ee2f743..3291e3b 100644 --- a/src/agentmsgs/core/types.py +++ b/src/agentmsgs/core/types.py @@ -31,3 +31,19 @@ class Thread: id: uuid.UUID participants: frozenset[Agent] created_at: datetime.datetime + + +@dataclass(frozen=True, slots=True) +class ThreadInfo: + thread: Thread + message_count: int + latest_message: Message | None + + +@dataclass(frozen=True, slots=True) +class ThreadReadState: + agent: Agent + cursor_seq: int + latest_seq: int + unread_count: int + unread_from_others_count: int diff --git a/src/agentmsgs/mcp/server.py b/src/agentmsgs/mcp/server.py index 5ab403f..5862488 100644 --- a/src/agentmsgs/mcp/server.py +++ b/src/agentmsgs/mcp/server.py @@ -6,6 +6,7 @@ from fastmcp import FastMCP from fastmcp.utilities.logging import get_logger from agentmsgs.core.app import App +from agentmsgs.core.types import Agent, Message from agentmsgs.stores.sqlite import SQLiteStore from agentmsgs.utils import WORKSPACE_ROOT @@ -45,6 +46,65 @@ def create_thread(participant_names: list[str]) -> dict: } +def _agent_payload(agent: Agent) -> dict: + return {"id": str(agent.id), "name": agent.name} + + +def _message_payload(message: Message) -> dict: + return { + "id": str(message.id), + "seq": message.seq, + "sender": message.sender.name, + "content": message.content, + "ts": message.ts.isoformat(), + } + + +@mcp.tool +def thread_info(thread_id: str) -> dict | None: + """Return thread metadata and its latest activity.""" + from uuid import UUID + + info = _app.get_thread_info(UUID(thread_id)) + if info is None: + return None + return { + "id": str(info.thread.id), + "participants": [ + _agent_payload(agent) + for agent in sorted(info.thread.participants, key=lambda a: a.name) + ], + "created_at": info.thread.created_at.isoformat(), + "message_count": info.message_count, + "latest_message": ( + _message_payload(info.latest_message) + if info.latest_message is not None + else None + ), + } + + +@mcp.tool +def thread_status(thread_id: str, agent_name: str) -> dict | None: + """Return an agent's read state in a thread.""" + from uuid import UUID + + agent = _app.get_or_create_agent(agent_name) + state = _app.get_thread_read_state(UUID(thread_id), agent) + if state is None: + return None + return { + "thread_id": thread_id, + "agent": _agent_payload(state.agent), + "cursor_seq": state.cursor_seq, + "latest_seq": state.latest_seq, + "unread_count": state.unread_count, + "unread_from_others_count": state.unread_from_others_count, + "has_unread": state.unread_count > 0, + "has_unread_from_others": state.unread_from_others_count > 0, + } + + @mcp.tool def join_thread(thread_id: str, agent_name: str) -> dict: """Add an agent to an existing thread.""" diff --git a/src/agentmsgs/stores/memory.py b/src/agentmsgs/stores/memory.py index 6016da3..773558a 100644 --- a/src/agentmsgs/stores/memory.py +++ b/src/agentmsgs/stores/memory.py @@ -4,7 +4,7 @@ import datetime import uuid from collections import defaultdict -from agentmsgs.core.types import Agent, Message, Thread +from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState class InMemoryStore: @@ -66,6 +66,35 @@ class InMemoryStore: def get_thread(self, id: uuid.UUID) -> Thread | None: return self._threads.get(id) + def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None: + thread = self.get_thread(id) + if thread is None: + return None + messages = self._msgs.get(id, []) + return ThreadInfo( + thread=thread, + message_count=len(messages), + latest_message=messages[-1] if messages else None, + ) + + def get_thread_read_state( + self, id: uuid.UUID, agent: Agent + ) -> ThreadReadState | None: + if self.get_thread(id) is None: + return None + messages = self._msgs.get(id, []) + cursor_seq = self.get_cursor(id, agent) + unread = [message for message in messages if message.seq > cursor_seq] + return ThreadReadState( + agent=agent, + cursor_seq=cursor_seq, + latest_seq=messages[-1].seq if messages else 0, + unread_count=len(unread), + unread_from_others_count=sum( + message.sender.id != agent.id for message in unread + ), + ) + def find_threads(self, containing: set[Agent]) -> list[Thread]: c = set(containing) return [t for t in self._threads.values() if c <= t.participants] diff --git a/src/agentmsgs/stores/sqlite.py b/src/agentmsgs/stores/sqlite.py index ab2ba8c..fc29663 100644 --- a/src/agentmsgs/stores/sqlite.py +++ b/src/agentmsgs/stores/sqlite.py @@ -3,9 +3,11 @@ from __future__ import annotations import datetime import sqlite3 import uuid +from collections.abc import Iterator +from contextlib import contextmanager from pathlib import Path -from agentmsgs.core.types import Agent, Message, Thread +from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState from agentmsgs.utils import WORKSPACE_ROOT _SCHEMA = """ @@ -44,6 +46,17 @@ class SQLiteStore: pass self._db.commit() + @contextmanager + def _read_transaction(self) -> Iterator[None]: + self._db.execute("BEGIN") + try: + yield + except Exception: + self._db.execute("ROLLBACK") + raise + else: + self._db.execute("COMMIT") + # -- agents -- def get_or_create_agent(self, name: str) -> Agent: @@ -150,6 +163,60 @@ class SQLiteStore: created_at=created_at, ) + def get_thread_info(self, id: uuid.UUID) -> ThreadInfo | None: + with self._read_transaction(): + thread = self.get_thread(id) + if thread is None: + return None + cur = self._db.execute( + "SELECT COUNT(*), MAX(seq) FROM messages WHERE thread_id=?", (str(id),) + ) + count_row = cur.fetchone() + assert count_row is not None + message_count, latest_seq = count_row + latest_message = None + if latest_seq is not None: + latest_messages = self.list_messages( + id, after_seq=int(latest_seq) - 1 + ) + if latest_messages: + latest_message = latest_messages[0] + return ThreadInfo( + thread=thread, + message_count=int(message_count), + latest_message=latest_message, + ) + + def get_thread_read_state( + self, id: uuid.UUID, agent: Agent + ) -> ThreadReadState | None: + with self._read_transaction(): + if self.get_thread(id) is None: + return None + cursor_seq = self.get_cursor(id, agent) + latest_row = self._db.execute( + "SELECT COALESCE(MAX(seq), 0) FROM messages WHERE thread_id=?", + (str(id),), + ).fetchone() + assert latest_row is not None + unread_row = self._db.execute( + "SELECT COUNT(*) FROM messages WHERE thread_id=? AND seq>?", + (str(id), cursor_seq), + ).fetchone() + assert unread_row is not None + others_row = self._db.execute( + "SELECT COUNT(*) FROM messages WHERE thread_id=? AND seq>? AND sender_id<>?", + (str(id), cursor_seq, str(agent.id)), + ).fetchone() + assert others_row is not None + return ThreadReadState( + agent=agent, + cursor_seq=cursor_seq, + latest_seq=int(latest_row[0]), + unread_count=int(unread_row[0]), + unread_from_others_count=int(others_row[0]), + ) + def find_threads(self, containing: set[Agent]) -> list[Thread]: c = set(containing) if not c: diff --git a/tests/test_app.py b/tests/test_app.py index 2f2a8f4..2ea7ddd 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -15,6 +15,25 @@ def test_app_facade_with_memory(): assert app.has_unread(t.id, bob) is False +def test_app_thread_info_and_read_state(): + app = App(store=InMemoryStore()) + alice = app.get_or_create_agent("Alice") + bob = app.get_or_create_agent("Bob") + thread = app.create_thread(alice, bob) + latest = app.append(thread.id, alice, "hello") + + info = app.get_thread_info(thread.id) + state = app.get_thread_read_state(thread.id, bob) + + assert info is not None + assert info.latest_message == latest + assert info.message_count == 1 + assert state.cursor_seq == 0 + assert state.latest_seq == 1 + assert state.unread_count == 1 + assert state.unread_from_others_count == 1 + + def test_app_delete_account(): from agentmsgs.core.app import App from agentmsgs.stores.memory import InMemoryStore diff --git a/tests/test_mcp.py b/tests/test_mcp.py new file mode 100644 index 0000000..fcc5661 --- /dev/null +++ b/tests/test_mcp.py @@ -0,0 +1,51 @@ +import asyncio + +from agentmsgs.core.app import App +from agentmsgs.mcp import server +from agentmsgs.stores.memory import InMemoryStore + + +def test_thread_info_and_status_tools(): + app = App(store=InMemoryStore()) + alice = app.get_or_create_agent("Alice") + bob = app.get_or_create_agent("Bob") + thread = app.create_thread(alice, bob) + latest = app.append(thread.id, alice, "hello") + server._app = app + + info_result = asyncio.run( + server.mcp.call_tool("thread_info", {"thread_id": str(thread.id)}) + ) + status_result = asyncio.run( + server.mcp.call_tool( + "thread_status", + {"thread_id": str(thread.id), "agent_name": "Bob"}, + ) + ) + + assert info_result.structured_content["result"] == { + "id": str(thread.id), + "participants": [ + {"id": str(alice.id), "name": "Alice"}, + {"id": str(bob.id), "name": "Bob"}, + ], + "created_at": thread.created_at.isoformat(), + "message_count": 1, + "latest_message": { + "id": str(latest.id), + "seq": latest.seq, + "sender": latest.sender.name, + "content": latest.content, + "ts": latest.ts.isoformat(), + }, + } + assert status_result.structured_content["result"] == { + "thread_id": str(thread.id), + "agent": {"id": str(bob.id), "name": bob.name}, + "cursor_seq": 0, + "latest_seq": 1, + "unread_count": 1, + "unread_from_others_count": 1, + "has_unread": True, + "has_unread_from_others": True, + } diff --git a/tests/test_ops.py b/tests/test_ops.py index 4c52d75..9143097 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -39,3 +39,21 @@ def test_ops_join_and_delete(): assert c in t2.participants ops.delete_agent(s, c.id) assert s.get_agent_by_id(c.id) is None + + +def test_ops_thread_info_and_read_state(): + s = InMemoryStore() + a = ops.get_or_create_agent(s, "A") + b = ops.get_or_create_agent(s, "B") + t = ops.create_thread(s, {a, b}) + latest = ops.append_message(s, t.id, a, "hello") + + info = ops.get_thread_info(s, t.id) + state = ops.get_thread_read_state(s, t.id, b) + + assert info is not None + assert info.thread == t + assert info.latest_message == latest + assert state.agent == b + assert state.unread_count == 1 + assert state.unread_from_others_count == 1 diff --git a/tests/test_store_memory.py b/tests/test_store_memory.py index 693762f..b2b40be 100644 --- a/tests/test_store_memory.py +++ b/tests/test_store_memory.py @@ -1,3 +1,5 @@ +import uuid + from agentmsgs.stores.memory import InMemoryStore @@ -36,3 +38,51 @@ def test_memory_append_and_list(): m2 = s.append_message(t.id, b, "yo") assert m1.seq == 1 and m2.seq == 2 assert s.list_messages(t.id, after_seq=1) == [m2] + + +def test_memory_thread_info_and_read_state(): + s = InMemoryStore() + a = s.get_or_create_agent("A") + b = s.get_or_create_agent("B") + t = s.create_thread({a, b}) + s.append_message(t.id, a, "one") + s.append_message(t.id, b, "two") + m3 = s.append_message(t.id, a, "three") + + info = s.get_thread_info(t.id) + assert info is not None + assert info.thread == t + assert info.message_count == 3 + assert info.latest_message == m3 + + state = s.get_thread_read_state(t.id, a) + assert state.agent == a + assert state.cursor_seq == 0 + assert state.latest_seq == 3 + assert state.unread_count == 3 + assert state.unread_from_others_count == 1 + + s.set_cursor(t.id, a, 2) + state = s.get_thread_read_state(t.id, a) + assert state.cursor_seq == 2 + assert state.unread_count == 1 + assert state.unread_from_others_count == 0 + + +def test_memory_thread_info_for_empty_thread(): + s = InMemoryStore() + a = s.get_or_create_agent("A") + b = s.get_or_create_agent("B") + t = s.create_thread({a, b}) + + info = s.get_thread_info(t.id) + state = s.get_thread_read_state(t.id, a) + + assert info is not None + assert info.message_count == 0 + assert info.latest_message is None + assert state.latest_seq == 0 + assert state.unread_count == 0 + assert state.unread_from_others_count == 0 + assert s.get_thread_info(uuid.uuid4()) is None + assert s.get_thread_read_state(uuid.uuid4(), a) is None diff --git a/tests/test_store_protocol.py b/tests/test_store_protocol.py index d75d15f..2a48534 100644 --- a/tests/test_store_protocol.py +++ b/tests/test_store_protocol.py @@ -1,27 +1,40 @@ from typing import Protocol -from agentmsgs.core.store import Store +from agentmsgs.core.store import ( + AgentStore, + CursorStore, + MessageStore, + Store, + ThreadStore, +) -def test_store_is_protocol(): +def test_store_composes_domain_protocols(): assert issubclass(Store, Protocol) - # check required methods exist - for m in [ - "get_or_create_agent", - "get_agent_by_name", - "get_agent_by_id", - "create_agent", - "delete_agent", - "list_agents", - "create_thread", - "get_thread", - "find_threads", - "add_participant", - "remove_participant", - "delete_thread", - "append_message", - "list_messages", - "get_cursor", - "set_cursor", - ]: - assert hasattr(Store, m), f"missing {m}" + assert AgentStore in Store.__mro__ + assert ThreadStore in Store.__mro__ + assert MessageStore in Store.__mro__ + assert CursorStore in Store.__mro__ + + for protocol, methods in { + AgentStore: [ + "get_or_create_agent", + "get_agent_by_name", + "get_agent_by_id", + "create_agent", + "delete_agent", + "list_agents", + ], + ThreadStore: [ + "create_thread", + "get_thread", + "find_threads", + "add_participant", + "remove_participant", + "delete_thread", + ], + MessageStore: ["append_message", "list_messages"], + CursorStore: ["get_cursor", "set_cursor"], + }.items(): + for method in methods: + assert hasattr(protocol, method), f"missing {method} from {protocol.__name__}" diff --git a/tests/test_store_sqlite.py b/tests/test_store_sqlite.py index 76ab7d9..19d52e8 100644 --- a/tests/test_store_sqlite.py +++ b/tests/test_store_sqlite.py @@ -1,5 +1,6 @@ import pathlib import tempfile +import uuid from agentmsgs.stores.sqlite import SQLiteStore @@ -59,3 +60,74 @@ def test_sqlite_concurrent_append(): finally: s2.close() s1.close() + + +def test_sqlite_thread_info_and_read_state(): + with tempfile.TemporaryDirectory() as d: + p = pathlib.Path(d) / "info.db" + s = SQLiteStore(p) + try: + a = s.get_or_create_agent("A") + b = s.get_or_create_agent("B") + t = s.create_thread({a, b}) + s.append_message(t.id, a, "one") + s.append_message(t.id, b, "two") + latest = s.append_message(t.id, a, "three") + + info = s.get_thread_info(t.id) + assert info is not None + assert info.thread == t + assert info.message_count == 3 + assert info.latest_message == latest + + state = s.get_thread_read_state(t.id, a) + assert state.agent == a + assert state.cursor_seq == 0 + assert state.latest_seq == 3 + assert state.unread_count == 3 + assert state.unread_from_others_count == 1 + + s.set_cursor(t.id, a, 2) + state = s.get_thread_read_state(t.id, a) + assert state.cursor_seq == 2 + assert state.unread_count == 1 + assert state.unread_from_others_count == 0 + assert s.get_thread_info(uuid.uuid4()) is None + assert s.get_thread_read_state(uuid.uuid4(), a) is None + finally: + s.close() + + +def test_sqlite_thread_read_state_uses_consistent_snapshot(): + with tempfile.TemporaryDirectory() as d: + p = pathlib.Path(d) / "snapshot.db" + reader = SQLiteStore(p) + writer = SQLiteStore(p) + try: + a = reader.get_or_create_agent("A") + b = reader.get_or_create_agent("B") + thread = reader.create_thread({a, b}) + reader.append_message(thread.id, a, "one") + injected = False + + def append_before_unread_query(statement: str) -> None: + nonlocal injected + if ( + not injected + and statement.startswith("SELECT COUNT(*) FROM messages") + and " AND seq>" in statement + and "sender_id" not in statement + ): + injected = True + writer.append_message(thread.id, b, "concurrent") + + reader._db.set_trace_callback(append_before_unread_query) + state = reader.get_thread_read_state(thread.id, a) + + assert injected is True + assert state.latest_seq == 1 + assert state.unread_count == 1 + assert state.unread_from_others_count == 0 + finally: + reader.close() + writer.close() diff --git a/tests/test_types.py b/tests/test_types.py index a2ef689..4ab1ce5 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,7 +1,7 @@ import datetime import uuid -from agentmsgs.core.types import Agent, Thread +from agentmsgs.core.types import Agent, Message, Thread, ThreadInfo, ThreadReadState def test_agent_identity_on_id_not_name(): @@ -36,3 +36,28 @@ def test_thread_multiple_with_same_participants_allowed(): ) assert t1 != t2 assert t1.participants == t2.participants + + +def test_thread_info_and_read_state_hold_derived_data(): + now = datetime.datetime.now(datetime.UTC) + alice = Agent(id=uuid.uuid4(), name="Alice") + bob = Agent(id=uuid.uuid4(), name="Bob") + thread = Thread( + id=uuid.uuid4(), participants=frozenset({alice, bob}), created_at=now + ) + latest = Message( + id=uuid.uuid4(), sender=bob, content="hello", seq=1, ts=now + ) + info = ThreadInfo(thread=thread, message_count=1, latest_message=latest) + state = ThreadReadState( + agent=alice, + cursor_seq=0, + latest_seq=1, + unread_count=1, + unread_from_others_count=1, + ) + + assert info.thread == thread + assert info.latest_message == latest + assert state.agent == alice + assert state.unread_from_others_count == 1