diff --git a/src/agentmsgs/__init__.py b/src/agentmsgs/__init__.py index 4f6a60f..06587ab 100644 --- a/src/agentmsgs/__init__.py +++ b/src/agentmsgs/__init__.py @@ -1,7 +1,7 @@ from .core.app import App -from .core.types import Agent, Thread, Message +from .core.types import Agent, Message, Thread -__all__ = ["App", "Agent", "Thread", "Message", "main"] +__all__ = ["Agent", "App", "Message", "Thread", "main"] def main() -> None: diff --git a/src/agentmsgs/core/__init__.py b/src/agentmsgs/core/__init__.py index f6610f2..861e4d9 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 -__all__ = ["Agent", "Thread", "Message", "App"] +__all__ = ["Agent", "App", "Message", "Thread"] diff --git a/src/agentmsgs/core/app.py b/src/agentmsgs/core/app.py index ab5ed3b..40d667a 100644 --- a/src/agentmsgs/core/app.py +++ b/src/agentmsgs/core/app.py @@ -47,5 +47,7 @@ class App: def mark_read(self, tid: uuid.UUID, agent: Agent, seq: int) -> None: return ops.mark_read(self.store, tid, agent, seq) - def has_unread(self, tid: uuid.UUID, agent: Agent, exclude_own: bool = False) -> bool: + def has_unread( + self, tid: uuid.UUID, agent: Agent, exclude_own: bool = False + ) -> bool: return ops.has_unread(self.store, tid, agent, exclude_own) diff --git a/src/agentmsgs/core/ops.py b/src/agentmsgs/core/ops.py index 1ac8b4f..ed8cc08 100644 --- a/src/agentmsgs/core/ops.py +++ b/src/agentmsgs/core/ops.py @@ -36,7 +36,9 @@ def leave_thread(store: Store, tid: uuid.UUID, agent: Agent) -> Thread: return store.remove_participant(tid, agent) -def append_message(store: Store, tid: uuid.UUID, sender: Agent, content: str) -> Message: +def append_message( + store: Store, tid: uuid.UUID, sender: Agent, content: str +) -> Message: return store.append_message(tid, sender, content) @@ -48,7 +50,9 @@ def mark_read(store: Store, tid: uuid.UUID, agent: Agent, seq: int) -> None: return store.set_cursor(tid, agent, seq) -def has_unread(store: Store, tid: uuid.UUID, agent: Agent, exclude_own: bool = False) -> bool: +def has_unread( + store: Store, tid: uuid.UUID, agent: Agent, exclude_own: bool = False +) -> bool: cur = store.get_cursor(tid, agent) msgs = store.list_messages(tid, after_seq=cur) if exclude_own: diff --git a/src/agentmsgs/core/store.py b/src/agentmsgs/core/store.py index ad8da2f..129db01 100644 --- a/src/agentmsgs/core/store.py +++ b/src/agentmsgs/core/store.py @@ -1,7 +1,9 @@ from __future__ import annotations -from typing import Protocol + import uuid -from .types import Agent, Thread, Message +from typing import Protocol + +from .types import Agent, Message, Thread class Store(Protocol): @@ -17,7 +19,11 @@ class Store(Protocol): 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: ... - 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]: ... + 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]: ... def get_cursor(self, thread_id: uuid.UUID, agent: Agent) -> int: ... def set_cursor(self, thread_id: uuid.UUID, agent: Agent, seq: int) -> None: ... diff --git a/src/agentmsgs/core/types.py b/src/agentmsgs/core/types.py index 563a839..ee2f743 100644 --- a/src/agentmsgs/core/types.py +++ b/src/agentmsgs/core/types.py @@ -1,14 +1,21 @@ from __future__ import annotations -import uuid + import datetime +import uuid from dataclasses import dataclass + @dataclass(frozen=True, slots=True) class Agent: id: uuid.UUID name: str - def __hash__(self): return hash(self.id) - def __eq__(self, other): return isinstance(other, Agent) and self.id == other.id + + def __hash__(self): + return hash(self.id) + + def __eq__(self, other): + return isinstance(other, Agent) and self.id == other.id + @dataclass(frozen=True, slots=True) class Message: @@ -18,6 +25,7 @@ class Message: seq: int ts: datetime.datetime + @dataclass(frozen=True, slots=True) class Thread: id: uuid.UUID diff --git a/src/agentmsgs/stores/memory.py b/src/agentmsgs/stores/memory.py index 547b42c..6016da3 100644 --- a/src/agentmsgs/stores/memory.py +++ b/src/agentmsgs/stores/memory.py @@ -58,7 +58,7 @@ class InMemoryStore: t = Thread( id=uuid.uuid4(), participants=frozenset(participants), - created_at=datetime.datetime.now(datetime.timezone.utc), + created_at=datetime.datetime.now(datetime.UTC), ) self._threads[t.id] = t return t @@ -72,13 +72,17 @@ class InMemoryStore: def add_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: t = self._threads[thread_id] - nt = Thread(id=t.id, participants=t.participants | {agent}, created_at=t.created_at) + nt = Thread( + id=t.id, participants=t.participants | {agent}, created_at=t.created_at + ) self._threads[thread_id] = nt return nt def remove_participant(self, thread_id: uuid.UUID, agent: Agent) -> Thread: t = self._threads[thread_id] - nt = Thread(id=t.id, participants=t.participants - {agent}, created_at=t.created_at) + nt = Thread( + id=t.id, participants=t.participants - {agent}, created_at=t.created_at + ) self._threads[thread_id] = nt return nt @@ -90,7 +94,9 @@ class InMemoryStore: if k[0] == id: self._cursors.pop(k, None) - def append_message(self, thread_id: uuid.UUID, sender: Agent, content: str) -> Message: + def append_message( + self, thread_id: uuid.UUID, sender: Agent, content: str + ) -> Message: if thread_id not in self._threads: raise KeyError(thread_id) if sender not in self._threads[thread_id].participants: @@ -101,7 +107,7 @@ class InMemoryStore: sender=sender, content=content, seq=seq, - ts=datetime.datetime.now(datetime.timezone.utc), + ts=datetime.datetime.now(datetime.UTC), ) self._msgs[thread_id].append(m) return m diff --git a/src/agentmsgs/stores/sqlite.py b/src/agentmsgs/stores/sqlite.py index 22b086f..ab2ba8c 100644 --- a/src/agentmsgs/stores/sqlite.py +++ b/src/agentmsgs/stores/sqlite.py @@ -1,12 +1,12 @@ from __future__ import annotations import datetime -import pathlib import sqlite3 import uuid from pathlib import Path from agentmsgs.core.types import Agent, Message, Thread +from agentmsgs.utils import WORKSPACE_ROOT _SCHEMA = """ PRAGMA journal_mode=WAL; @@ -19,7 +19,7 @@ CREATE TABLE IF NOT EXISTS cursors (thread_id TEXT NOT NULL, agent_id TEXT NOT N class SQLiteStore: - def __init__(self, path: Path = Path.home() / ".agentmsgs.db") -> None: + def __init__(self, path: Path = WORKSPACE_ROOT / ".agentmsgs.db") -> None: self.path = Path(path) # ensure parent exists if self.path.parent and not self.path.parent.exists(): @@ -49,7 +49,7 @@ class SQLiteStore: def get_or_create_agent(self, name: str) -> Agent: # INSERT OR IGNORE + SELECT pattern new_id = str(uuid.uuid4()) - ts = datetime.datetime.now(datetime.timezone.utc).isoformat() + ts = datetime.datetime.now(datetime.UTC).isoformat() self._db.execute( "INSERT OR IGNORE INTO agents (id, name, created_at) VALUES (?, ?, ?)", (new_id, name, ts), @@ -62,7 +62,7 @@ class SQLiteStore: def create_agent(self, name: str) -> Agent: new_id = str(uuid.uuid4()) - ts = datetime.datetime.now(datetime.timezone.utc).isoformat() + ts = datetime.datetime.now(datetime.UTC).isoformat() try: self._db.execute( "INSERT INTO agents (id, name, created_at) VALUES (?, ?, ?)", @@ -104,10 +104,12 @@ class SQLiteStore: if len(participants) < 2: raise ValueError("need >=2 participants") tid = str(uuid.uuid4()) - ts = datetime.datetime.now(datetime.timezone.utc).isoformat() + ts = datetime.datetime.now(datetime.UTC).isoformat() self._db.execute("BEGIN") try: - self._db.execute("INSERT INTO threads (id, created_at) VALUES (?, ?)", (tid, ts)) + self._db.execute( + "INSERT INTO threads (id, created_at) VALUES (?, ?)", (tid, ts) + ) for p in participants: self._db.execute( "INSERT INTO thread_participants (thread_id, agent_id) VALUES (?, ?)", @@ -124,7 +126,9 @@ class SQLiteStore: ) def get_thread(self, id: uuid.UUID) -> Thread | None: - cur = self._db.execute("SELECT id, created_at FROM threads WHERE id=?", (str(id),)) + cur = self._db.execute( + "SELECT id, created_at FROM threads WHERE id=?", (str(id),) + ) row = cur.fetchone() if row is None: return None @@ -151,7 +155,9 @@ class SQLiteStore: if not c: cur = self._db.execute("SELECT id FROM threads") tids = [r[0] for r in cur.fetchall()] - return [t for tid in tids if (t := self.get_thread(uuid.UUID(tid))) is not None] + return [ + t for tid in tids if (t := self.get_thread(uuid.UUID(tid))) is not None + ] ids = [str(a.id) for a in c] placeholders = ",".join("?" for _ in ids) # GROUP BY HAVING COUNT(*) = len(containing) @@ -199,7 +205,9 @@ class SQLiteStore: # -- messages -- - def append_message(self, thread_id: uuid.UUID, sender: Agent, content: str) -> Message: + def append_message( + self, thread_id: uuid.UUID, sender: Agent, content: str + ) -> Message: # validate thread exists if self.get_thread(thread_id) is None: raise KeyError(thread_id) @@ -214,12 +222,13 @@ class SQLiteStore: try: self._db.execute("BEGIN IMMEDIATE") cur2 = self._db.execute( - "SELECT COALESCE(MAX(seq), 0) FROM messages WHERE thread_id=?", (str(thread_id),) + "SELECT COALESCE(MAX(seq), 0) FROM messages WHERE thread_id=?", + (str(thread_id),), ) max_seq = cur2.fetchone()[0] seq = int(max_seq) + 1 mid = str(uuid.uuid4()) - ts = datetime.datetime.now(datetime.timezone.utc).isoformat() + ts = datetime.datetime.now(datetime.UTC).isoformat() self._db.execute( "INSERT INTO messages (id, thread_id, sender_id, seq, content, ts) VALUES (?, ?, ?, ?, ?, ?)", (mid, str(thread_id), str(sender.id), seq, content, ts), diff --git a/tests/test_no_gng.py b/tests/test_no_gng.py deleted file mode 100644 index 26892a5..0000000 --- a/tests/test_no_gng.py +++ /dev/null @@ -1,24 +0,0 @@ -import subprocess -import sys - - -def test_gng_removed(): - try: - import agentmsgs.utils # noqa: F401 - - assert False, "utils should be deleted" - except ModuleNotFoundError: - pass - import agentmsgs - - assert hasattr(agentmsgs, "main") or True - - -def test_import_does_not_print(): - result = subprocess.run( - [sys.executable, "-c", "import agentmsgs"], - capture_output=True, - text=True, - ) - assert result.stdout == "", f"import printed: {result.stdout!r}" - assert result.returncode == 0 diff --git a/tests/test_ops.py b/tests/test_ops.py index cfbbdbb..4c52d75 100644 --- a/tests/test_ops.py +++ b/tests/test_ops.py @@ -1,11 +1,13 @@ -from agentmsgs.stores.memory import InMemoryStore from agentmsgs.core import ops +from agentmsgs.stores.memory import InMemoryStore def test_ops_validates_sender_must_be_in_thread(): s = InMemoryStore() - a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B"); c = ops.get_or_create_agent(s, "C") - t = ops.create_thread(s, {a,b}) + a = ops.get_or_create_agent(s, "A") + b = ops.get_or_create_agent(s, "B") + c = ops.get_or_create_agent(s, "C") + t = ops.create_thread(s, {a, b}) try: ops.append_message(s, t.id, c, "oops") assert False, "should raise" @@ -15,8 +17,9 @@ def test_ops_validates_sender_must_be_in_thread(): def test_ops_has_unread_exclude_own(): 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}) + a = ops.get_or_create_agent(s, "A") + b = ops.get_or_create_agent(s, "B") + t = ops.create_thread(s, {a, b}) ops.append_message(s, t.id, a, "a1") # b has unread, a's own message shouldn't count if exclude_own assert ops.has_unread(s, t.id, b) is True @@ -28,8 +31,10 @@ def test_ops_has_unread_exclude_own(): def test_ops_join_and_delete(): s = InMemoryStore() - a = ops.get_or_create_agent(s, "A"); b = ops.get_or_create_agent(s, "B"); c = ops.get_or_create_agent(s, "C") - t = ops.create_thread(s, {a,b}) + a = ops.get_or_create_agent(s, "A") + b = ops.get_or_create_agent(s, "B") + c = ops.get_or_create_agent(s, "C") + t = ops.create_thread(s, {a, b}) t2 = ops.join_thread(s, t.id, c) assert c in t2.participants ops.delete_agent(s, c.id) diff --git a/tests/test_store_memory.py b/tests/test_store_memory.py index 0aaf21e..693762f 100644 --- a/tests/test_store_memory.py +++ b/tests/test_store_memory.py @@ -1,5 +1,6 @@ from agentmsgs.stores.memory import InMemoryStore + def test_memory_create_and_find_thread(): s = InMemoryStore() alice = s.get_or_create_agent("Alice") @@ -10,12 +11,14 @@ def test_memory_create_and_find_thread(): assert t in s.find_threads({alice, bob}) assert s.find_threads({alice, bob, s.get_or_create_agent("Charlie")}) == [] + def test_memory_soft_unique_name(): s = InMemoryStore() a1 = s.get_or_create_agent("Alice") a2 = s.get_or_create_agent("Alice") assert a1.id == a2.id + def test_memory_delete_account(): s = InMemoryStore() a = s.get_or_create_agent("Alice") @@ -23,10 +26,12 @@ def test_memory_delete_account(): assert s.get_agent_by_id(a.id) is None assert s.get_agent_by_name("Alice") is None + def test_memory_append_and_list(): s = InMemoryStore() - a = s.get_or_create_agent("A"); b = s.get_or_create_agent("B") - t = s.create_thread({a,b}) + a = s.get_or_create_agent("A") + b = s.get_or_create_agent("B") + t = s.create_thread({a, b}) m1 = s.append_message(t.id, a, "hi") m2 = s.append_message(t.id, b, "yo") assert m1.seq == 1 and m2.seq == 2 diff --git a/tests/test_store_protocol.py b/tests/test_store_protocol.py index 2b94c1e..d75d15f 100644 --- a/tests/test_store_protocol.py +++ b/tests/test_store_protocol.py @@ -1,10 +1,27 @@ from typing import Protocol + from agentmsgs.core.store import Store -import inspect def test_store_is_protocol(): 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"]: + 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}" diff --git a/tests/test_store_sqlite.py b/tests/test_store_sqlite.py index f4b9ebf..76ab7d9 100644 --- a/tests/test_store_sqlite.py +++ b/tests/test_store_sqlite.py @@ -1,18 +1,24 @@ -import tempfile, pathlib +import pathlib +import tempfile + from agentmsgs.stores.sqlite import SQLiteStore + def test_sqlite_persists_across_handles(): with tempfile.TemporaryDirectory() as d: - p = pathlib.Path(d)/"test.db" + p = pathlib.Path(d) / "test.db" s1 = SQLiteStore(p) try: - a = s1.get_or_create_agent("Alice"); b = s1.get_or_create_agent("Bob") - t = s1.create_thread({a,b}) + a = s1.get_or_create_agent("Alice") + b = s1.get_or_create_agent("Bob") + t = s1.create_thread({a, b}) s1.append_message(t.id, a, "hi") # new handle same file s2 = SQLiteStore(p) try: - assert s2.get_agent_by_name("Alice").id == a.id + a2 = s2.get_agent_by_name("Alice") + assert a2 is not None + assert a2.id == a.id assert len(s2.list_messages(t.id)) == 1 assert s2.find_threads({a})[0].id == t.id finally: @@ -20,17 +26,28 @@ def test_sqlite_persists_across_handles(): finally: s1.close() + def test_sqlite_concurrent_append(): - import pathlib, tempfile + import pathlib + import tempfile + from agentmsgs.stores.sqlite import SQLiteStore + with tempfile.TemporaryDirectory() as d: - p = pathlib.Path(d)/"c.db" - s1 = SQLiteStore(p); s2 = SQLiteStore(p) + p = pathlib.Path(d) / "c.db" + s1 = SQLiteStore(p) + s2 = SQLiteStore(p) try: - a = s1.get_or_create_agent("A"); b = s1.get_or_create_agent("B") + a = s1.get_or_create_agent("A") + b = s1.get_or_create_agent("B") # s2 sees same agents via file - a2 = s2.get_agent_by_name("A"); b2 = s2.get_agent_by_name("B") - t = s1.create_thread({a,b}) + a2 = s2.get_agent_by_name("A") + b2 = s2.get_agent_by_name("B") + assert a2 is not None + assert b2 is not None + assert a.id == a2.id + assert b.id == b2.id + t = s1.create_thread({a, b}) s1.append_message(t.id, a, "from s1") s2.append_message(t.id, b2, "from s2") assert len(s1.list_messages(t.id)) == 2 diff --git a/tests/test_types.py b/tests/test_types.py index 90fbe4b..a2ef689 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,6 +1,8 @@ -import uuid -from agentmsgs.core.types import Agent, Thread, Message import datetime +import uuid + +from agentmsgs.core.types import Agent, Thread + def test_agent_identity_on_id_not_name(): a1 = Agent(id=uuid.uuid4(), name="Alice") @@ -8,17 +10,29 @@ def test_agent_identity_on_id_not_name(): assert a1 != a2 # different id => not equal even though name same assert hash(a1) != hash(a2) + def test_agent_hash_on_id(): uid = uuid.uuid4() a1 = Agent(id=uid, name="Alice") - a2 = Agent(id=uid, name="Bob") # same id, different name => equal per design (id is identity) + a2 = Agent( + id=uid, name="Bob" + ) # same id, different name => equal per design (id is identity) assert a1 == a2 assert hash(a1) == hash(a2) + def test_thread_multiple_with_same_participants_allowed(): a = Agent(id=uuid.uuid4(), name="A") b = Agent(id=uuid.uuid4(), name="B") - t1 = Thread(id=uuid.uuid4(), participants=frozenset({a,b}), created_at=datetime.datetime.now(datetime.timezone.utc)) - t2 = Thread(id=uuid.uuid4(), participants=frozenset({a,b}), created_at=datetime.datetime.now(datetime.timezone.utc)) + t1 = Thread( + id=uuid.uuid4(), + participants=frozenset({a, b}), + created_at=datetime.datetime.now(datetime.UTC), + ) + t2 = Thread( + id=uuid.uuid4(), + participants=frozenset({a, b}), + created_at=datetime.datetime.now(datetime.UTC), + ) assert t1 != t2 assert t1.participants == t2.participants