diff --git a/.gitignore b/.gitignore index 9201788..fc9a1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,6 @@ impl/ **CLAUDE.md **AGENT.md # …except the repo's canonical agent guide at the root. -!/AGENT.md \ No newline at end of file +!/AGENT.md +# Local planning workspace (Nimbalyst) — not product source +nimbalyst-local/ diff --git a/stacklets/core/bot-runner/microbot.py b/stacklets/core/bot-runner/microbot.py index 71f403c..de621a3 100644 --- a/stacklets/core/bot-runner/microbot.py +++ b/stacklets/core/bot-runner/microbot.py @@ -188,6 +188,7 @@ async def on_invite(room, event): logger.info("[{}] Join result: {}", self.name, resp) if isinstance(resp, JoinResponse): self._pending_room_joins.add(room.room_id) + self._anchor_cursor_on_join(room.room_id) self._client.add_event_callback(on_invite, InviteMemberEvent) @@ -1395,3 +1396,18 @@ def _load_cursors(self): def _advance_cursor(self, room_id, server_timestamp): self._cursors[room_id] = server_timestamp self._cursor_file.write_text(json.dumps(self._cursors)) + + def _anchor_cursor_on_join(self, room_id) -> None: + """Start a freshly joined room's cursor at the join itself. + + Without this the anchor is set by whichever drain first notices + the room, which also discards everything sent in between. That + window is not theoretical: the bot posts a welcome from the join + handler, so the first thing a member types in reply to it lands + squarely inside it and was dropped in silence. + + A room already carrying a cursor keeps it, so a re-invite cannot + rewind or skip a room the bot was already following. + """ + if room_id not in self._cursors: + self._advance_cursor(room_id, int(time.time() * 1000)) diff --git a/tests/integration/test_room_modes_e2e.py b/tests/integration/test_room_modes_e2e.py index cd4ad03..1619b1d 100644 --- a/tests/integration/test_room_modes_e2e.py +++ b/tests/integration/test_room_modes_e2e.py @@ -4,13 +4,15 @@ behave around per-room config and user-driven reactions. It is the contract; the implementation is expected to satisfy it. -> STATUS: UNVERIFIED against the integration rig. The behavior below was -> verified by hand against a running instance on 2026-06-26, but this -> test has not yet been run green through `stacktests` (server -> test.local). It is marked `unverified` + `xfail(strict=False)` so it -> neither blocks the suite nor is trusted until reconciled. Run it at the -> end of the iteration and fix the test (or the implementation) before -> tagging the next beta. Do not silently delete it to make CI green. +> STATUS: verified green through `stacktests` on 2026-08-02. It ran red +> first, and reconciling it took one fix on each side. The test was +> treating the bot's join as readiness, but a join is an +> `m.room.member` state event, so a sender-only wait clears the instant +> the invite is accepted rather than when the bot starts listening. The +> implementation anchored a room's message cursor at the first drain +> that noticed the room, which silently swallowed anything sent between +> joining and that drain — including the first thing a member types in +> reply to the welcome. Both are fixed; see `_anchor_cursor_on_join`. The intent, in one place: @@ -40,7 +42,6 @@ import time -import pytest from nio import AsyncClient from tests.integration.matrix import ( @@ -55,9 +56,6 @@ MARGE = mxid("marge") EYES, CHECK = "👀", "✅" -pytestmark = [pytest.mark.unverified] - - def _norm(key: str) -> str: return (key or "").replace("\uFE0F", "").strip() @@ -94,11 +92,6 @@ async def _react(client, room_id: str, target: str, key: str) -> None: "rel_type": "m.annotation", "event_id": target, "key": key}}) -@pytest.mark.xfail( - reason="UNVERIFIED intent spec: not yet run green against the rig. " - "Reconcile before the next beta tag.", - strict=False, -) async def test_room_modes_and_bookmark_reactions(homer, matrix): """The full contract, driven as Homer in a 3-member group room. @@ -120,16 +113,26 @@ async def test_room_modes_and_bookmark_reactions(homer, matrix): room = created.room_id await marge.join(room) - # The bot auto-accepts the invite and posts a welcome on join; - # wait for any sign of it (cold start can take ~40s). + # The bot auto-accepts the invite and posts a welcome on join. + # Wait for the welcome *message*, not merely for the archivist to + # appear in the timeline: a join is an `m.room.member` state event + # with the bot as sender, so a sender-only predicate is satisfied + # the instant it accepts the invite, before it is processing + # anything. A command sent in that window is swallowed as sync + # history and answered by nothing. Posting the welcome is the + # bot's own "I am listening" signal, so that is the gate. + # (Cold start can take ~40s.) joined = await wait_for_room_event( homer, room, - lambda e: getattr(e, "sender", None) == ARCHIVIST, + lambda e: ( + getattr(e, "sender", None) == ARCHIVIST + and (getattr(e, "body", "") or "").strip() != "" + ), timeout=130, ) assert joined, \ - "archivist never joined or responded in the room" + "archivist never posted its welcome, so it is not listening yet" # 1. Switch the room to react mode. await _send(homer, room, "!config process react") diff --git a/tests/stacklets/test_microbot.py b/tests/stacklets/test_microbot.py index 00cd041..bef581d 100644 --- a/tests/stacklets/test_microbot.py +++ b/tests/stacklets/test_microbot.py @@ -261,6 +261,53 @@ async def handler(room, event): assert seen == [] # history is not replayed assert bot._cursors[room.room_id] > 0 # cursor anchored at ~now + @pytest.mark.asyncio + async def test_a_room_joined_by_invite_delivers_the_first_message(self, tmp_path): + """Anchoring at the join, not at the first drain, is what makes the + very first thing a member types get answered. + + The bot welcomes the room from its join handler, so a member + replying to that welcome writes into the gap between joining and + the next drain. Anchoring only at drain time swallows exactly + that message: the bot says it is listening and then ignores the + first thing it is told.""" + seen = [] + + async def handler(room, event): + seen.append(event.server_timestamp) + + bot, client = _build_bot(tmp_path, handler=handler) + room = _room() + client.rooms = {room.room_id: room} + + bot._anchor_cursor_on_join(room.room_id) + joined_at = bot._cursors[room.room_id] + + # What a member types right after the welcome, plus a piece of + # the room's history from before the bot was ever invited. + client.timeline = [ + _event(ts=joined_at + 1, eid="$first"), + _event(ts=joined_at - 5_000, eid="$older"), + ] + + await bot._drain() + + assert seen == [joined_at + 1], \ + "the first message after joining was swallowed" + + @pytest.mark.asyncio + async def test_rejoining_does_not_rewind_a_room_already_followed(self, tmp_path): + """A re-invite must not move the cursor. Otherwise leaving and + being re-added would replay or skip, depending on which way it + moved.""" + bot, _client = _build_bot(tmp_path, handler=lambda r, e: None) + room = _room() + bot._cursors[room.room_id] = 4_242 + + bot._anchor_cursor_on_join(room.room_id) + + assert bot._cursors[room.room_id] == 4_242 + # ── Timeout ──────────────────────────────────────────────────────────────