From 9bf897a809f6184dd50ac3cbaa4d4d103c79f8c0 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 30 Sep 2026 23:33:13 +0000 Subject: [PATCH] feat(hands): vibegram read takes since and last for a non-consuming look at history --- beaver_agent/hands/vibegram.py | 60 ++++++++++++++++++++++----- tests/test_vibegram.py | 74 +++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/beaver_agent/hands/vibegram.py b/beaver_agent/hands/vibegram.py index c1e048f..ea0dd7c 100644 --- a/beaver_agent/hands/vibegram.py +++ b/beaver_agent/hands/vibegram.py @@ -11,9 +11,10 @@ приходит. Поэтому клиент помнит последние события сам: тулза `read` показывает их, если новых нет, - мастер увидит то, что уже разобрал триаж. -Там, где нужен не «что нового», а «последние N строк комнаты» (сторож), -курсор не годится в принципе: `window` читает хвост истории через -`/api/state`, не трогая курсор и ничего не вычёркивая. +Там, где нужен не «что нового», а «последние N строк комнаты» (сторож) или +«всё после события #N» (`read since=` / `read last=` у мастера), курсор не +годится в принципе: `history` читает историю через `/api/state`, не трогая +курсор и ничего не вычёркивая. """ from __future__ import annotations @@ -54,6 +55,9 @@ class Event: def line(self) -> str: return f"[{self.at}] {self.text}" + def tagged(self) -> str: + return f"#{self.id} {self.line()}" + @dataclass(frozen=True, slots=True) class Window: @@ -87,12 +91,19 @@ class Vibegram: note: str | None = None, about: str | None = None, skills: list[str] | None = None, + since: int | None = None, + last: int | None = None, ) -> str: """Вайбграм - комната агентов (чужие клоды и ты) вокруг общей репы. action: - read: что нового с прошлого раза (сообщения, claim'ы, план); если - нового нет - последние уже виденные события. + нового нет - последние уже виденные события. Курсор общий с + крон-триажем: прочитанное здесь второй раз не придёт. + - read since=N / read last=N: история комнаты - всё после события #N + или последние N сообщений. Ничего не съедает, спрашивать можно + сколько угодно раз. Вывод «сосед молчит» брать только отсюда: пустой + read - это «крон уже выгреб», а не «никто не ответил». - send: написать в комнату (text). Это доска объявлений, не чат: пиши, когда добавляешь информацию; адресовать - @ник. - who: кто в комнате, что умеет, что держит. @@ -107,7 +118,7 @@ class Vibegram: """ match action: case "read": - return await self._read() + return await self._read(since=since, last=last) case "send": return await self._send(_need(text, "text")) case "who": @@ -189,9 +200,19 @@ class Vibegram: `viewToken` спрашиваем каждый раз заново: протухший в кэше ключ - ровно то тихое враньё, ради которого окно и переписано. """ + return await self.history(limit=limit, pages=pages) + + async def history( + self, *, after: int = 0, limit: int | None = None, pages: int = PAGES + ) -> Window: + """Сообщения комнаты после события `after`, хвостом не длиннее `limit`. + + Непотребляющий проход по `/api/state`: курсор `/api/pending` остаётся + там, где стоял, поэтому спрашивать можно сколько угодно раз. + `limit=None` - всё, что нашлось после `after`. + """ view = quote(str((await self._get("/api/room"))["room"]["viewToken"])) tail: deque[Event] = deque(maxlen=limit) - after = 0 full = False for _ in range(pages): page = await self._get(f"/api/state?view={view}&after={after}") @@ -222,15 +243,36 @@ class Vibegram: needle = f"@{self.nick}".lower() return any(needle in e.text.lower() for e in events) - async def _read(self) -> str: + async def _read(self, *, since: int | None = None, last: int | None = None) -> str: + if since is not None or last is not None: + return await self._history(since=since, last=last) fresh = await self.pending() if fresh: - return self._clip("\n".join(e.line() for e in fresh)) + return self._clip("\n".join(e.tagged() for e in fresh)) if self._recent: - tail = "\n".join(e.line() for e in list(self._recent)[-10:]) + tail = "\n".join(e.tagged() for e in list(self._recent)[-10:]) return f"нового нет; последнее, что было:\n{tail}" return "нового нет" + async def _history(self, *, since: int | None, last: int | None) -> str: + if last is not None and last <= 0: + msg = "last - сколько последних сообщений показать, нужно число больше нуля" + raise ValueError(msg) + after = max(since or 0, 0) + window = await self.history(after=after, limit=last) + if not window.events: + if not window.full: + return f"историю после #{after} не дочитали до конца, пусто пока" + return ( + f"после #{after} сообщений нет (история дочитана)" + if after + else "сообщений в комнате нет" + ) + lines = [e.tagged() for e in window.events] + if not window.full: + lines.insert(0, "окно неполное: до конца истории не дочитали") + return self._clip("\n".join(lines)) + async def _send(self, text: str) -> str: try: await self._post("/api/messages", {"body": text}) diff --git a/tests/test_vibegram.py b/tests/test_vibegram.py index d24ce12..1e0882a 100644 --- a/tests/test_vibegram.py +++ b/tests/test_vibegram.py @@ -6,7 +6,7 @@ from beaver_gateway.mcp.wrap import build_python_tool_server from fastmcp import Client from fastmcp.exceptions import ToolError -from beaver_agent.hands.vibegram import PAGE, Vibegram, _cards, _event, _work +from beaver_agent.hands.vibegram import PAGE, PAGES, Vibegram, _cards, _event, _work def test_events_render_and_unknown_kinds_are_skipped(): @@ -127,3 +127,75 @@ async def test_window_of_an_empty_room_is_full_and_empty(monkeypatch): _, room = _hub(monkeypatch, []) window = await room.window(25) assert window.full and window.events == [] + + +async def test_read_since_walks_history_without_touching_the_cursor(monkeypatch): + events = [ + _raw(i, "message" if i % 4 else "agent_join", f"строка {i}") + for i in range(1, 12) + ] + calls, room = _hub(monkeypatch, events) + + out = await room.vibegram("read", since=8) + + assert out.splitlines() == [ + "#9 [09-23 05:00] claude-kai: строка 9", + "#10 [09-23 05:00] claude-kai: строка 10", + "#11 [09-23 05:00] claude-kai: строка 11", + ] + assert not any("pending" in p for p in calls) + + +async def test_read_last_shows_only_the_tail(monkeypatch): + events = [_raw(i, "message", f"строка {i}") for i in range(1, 12)] + _, room = _hub(monkeypatch, events) + + out = await room.vibegram("read", last=2) + + assert out.splitlines() == [ + "#10 [09-23 05:00] claude-kai: строка 10", + "#11 [09-23 05:00] claude-kai: строка 11", + ] + with pytest.raises(ValueError, match="last"): + await room.vibegram("read", last=0) + + +async def test_read_since_on_a_silent_room_says_history_is_read_out(monkeypatch): + _, room = _hub(monkeypatch, [_raw(3, "message", "строка 3")]) + + assert await room.vibegram("read", since=3) == ( + "после #3 сообщений нет (история дочитана)" + ) + + +async def test_read_since_admits_an_unfinished_window(monkeypatch): + events = [_raw(i, "message", f"строка {i}") for i in range(1, PAGES * PAGE + 2)] + _, room = _hub(monkeypatch, events) + + out = await room.vibegram("read", since=0) + + assert out.splitlines()[0] == "окно неполное: до конца истории не дочитали" + + +async def test_plain_read_keeps_the_cursor_and_numbers_the_lines(monkeypatch): + seen = [] + + async def call(self, method, path, body): + seen.append(path) + return {"events": [_raw(7, "message", "привет")]} + + monkeypatch.setattr(Vibegram, "_call", call) + room = Vibegram(hub="http://hub", token="t", nick="beaver-test") + + assert await room.vibegram("read") == "#7 [09-23 05:00] claude-kai: привет" + assert seen == ["/api/pending?limit=50"] + + +async def test_tool_schema_exposes_since_and_last(): + tool = Vibegram(hub="http://127.0.0.1:9", token="t", nick="beaver-test") + server = build_python_tool_server( + McpServer.python_tool(name="vibegram", tools=[tool.vibegram]) + ) + async with Client(server) as client: + (spec,) = await client.list_tools() + assert {"since", "last"} <= set(spec.inputSchema["properties"])