feat: add thread info and status reads
This commit is contained in:
16 files changed
+496
-33
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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__}"
|
||||
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user