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

+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