From 055eedee24a742cfc16ea55dbdff3023c12b833d Mon Sep 17 00:00:00 2001 From: ethanfel Date: Sun, 28 Jun 2026 13:20:19 +0200 Subject: [PATCH] feat: ChannelStore inbox (graph->agent) with emit/pull --- src/bridge/store.py | 63 ++++++++++++++++++++++++++++++++ tests/bridge/__init__.py | 0 tests/bridge/test_store_inbox.py | 21 +++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/bridge/store.py create mode 100644 tests/bridge/__init__.py create mode 100644 tests/bridge/test_store_inbox.py diff --git a/src/bridge/store.py b/src/bridge/store.py new file mode 100644 index 0000000..a79d036 --- /dev/null +++ b/src/bridge/store.py @@ -0,0 +1,63 @@ +import threading +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class _Slot: + text: Optional[str] = None + image_path: Optional[str] = None + turn: int = 0 + + +@dataclass +class _Channel: + inbox: _Slot = field(default_factory=_Slot) + outbox: _Slot = field(default_factory=_Slot) + last_consumed_out_turn: int = 0 + + +class ChannelStore: + _singleton: "ChannelStore | None" = None + + def __init__(self): + self._lock = threading.Lock() + self._cond = threading.Condition(self._lock) + self._channels: dict[str, _Channel] = {} + + # --- singleton plumbing --- + @classmethod + def instance(cls) -> "ChannelStore": + if cls._singleton is None: + cls._singleton = cls() + return cls._singleton + + @classmethod + def reset(cls) -> None: + cls._singleton = cls() + + def _chan(self, name: str) -> _Channel: + ch = self._channels.get(name) + if ch is None: + ch = _Channel() + self._channels[name] = ch + return ch + + # --- inbox: graph -> agent --- + def emit(self, channel: str, text: Optional[str] = None, + image_path: Optional[str] = None) -> int: + with self._cond: + ch = self._chan(channel) + ch.inbox.turn += 1 + ch.inbox.text = text + ch.inbox.image_path = image_path + self._cond.notify_all() + return ch.inbox.turn + + def pull(self, channel: str) -> dict: + with self._lock: + ch = self._channels.get(channel) + if ch is None: + return {"turn": 0, "text": None, "image_path": None} + s = ch.inbox + return {"turn": s.turn, "text": s.text, "image_path": s.image_path} diff --git a/tests/bridge/__init__.py b/tests/bridge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bridge/test_store_inbox.py b/tests/bridge/test_store_inbox.py new file mode 100644 index 0000000..eb1ca51 --- /dev/null +++ b/tests/bridge/test_store_inbox.py @@ -0,0 +1,21 @@ +from src.bridge.store import ChannelStore + +def test_emit_then_pull_returns_payload(): + s = ChannelStore.instance() + s.emit("main", text="hello", image_path="/tmp/a.png") + got = s.pull("main") + assert got["text"] == "hello" + assert got["image_path"] == "/tmp/a.png" + assert got["turn"] == 1 + +def test_pull_unknown_channel_is_empty_not_error(): + s = ChannelStore.instance() + got = s.pull("never") + assert got == {"turn": 0, "text": None, "image_path": None} + +def test_emit_bumps_turn_each_time(): + s = ChannelStore.instance() + s.emit("main", text="a") + s.emit("main", text="b") + assert s.pull("main")["turn"] == 2 + assert s.pull("main")["text"] == "b"