feat: add thread info and status reads
This commit is contained in:
16 files changed
+496
-33
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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