From 69a361c04ebe0863fe1c41ff1d93b8fc0a9feb04 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 2 Aug 2026 10:39:01 +0200 Subject: [PATCH 1/2] fix(core): answer the first message in a room you just joined The bot greets a new room with "I capture what your family drops here", and then ignored the very next thing dropped. Whoever invited it had to say the same thing twice, with nothing to explain why the first attempt did nothing. Each room has a cursor marking how far the bot has read, so a restart does not replay old chatter. For a room it had just joined that cursor was set by whichever poll first noticed the room, which also discarded everything sent in the meantime. The welcome arrives from the join itself, so a reply to it landed exactly in that gap. The cursor now starts at the join. Enables the room-modes spec, which has been parked as unverified since June and was failing for this reason. It was also waiting on the bot before sending, but a join is a membership change rather than a message, so it began talking before anyone was listening. --- stacklets/core/bot-runner/microbot.py | 16 ++++++++ tests/integration/test_room_modes_e2e.py | 43 ++++++++++++---------- tests/stacklets/test_microbot.py | 47 ++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 20 deletions(-) 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 ────────────────────────────────────────────────────────────── From 0541dfc12da74e837b1322a12b21f08b2690417f Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 2 Aug 2026 09:09:28 +0200 Subject: [PATCH 2/2] chore: keep the local planning workspace out of the repo Nimbalyst writes plan documents under nimbalyst-local/. They are working notes for one machine, not product source, and this repo is public. --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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/