feat: add thread info and status reads

This commit is contained in:
lda committed 2026-09-24 04:51:32 +07:00 Verified
1 parent 726f0c8b2a
commit d1fba584aa
16 files changed
+496 -33

No files matched your search

+10 -2
View File
@@ -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:
+2 -2
View File
@@ -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"]
+9 -1
View File
@@ -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))
+11 -1
View File
@@ -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)
+19 -2
View File
@@ -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
+16
View File
@@ -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
+60
View File
@@ -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."""
+30 -1
View File
@@ -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]
+68 -1
View File
@@ -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:
+19
View File
@@ -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
+51
View File
@@ -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,
}
+18
View File
@@ -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
+50
View File
@@ -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
+35 -22
View File
@@ -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__}"
+72
View File
@@ -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()
+26 -1
View File
@@ -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