Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,6 @@ impl/
**CLAUDE.md
**AGENT.md
# …except the repo's canonical agent guide at the root.
!/AGENT.md
!/AGENT.md
# Local planning workspace (Nimbalyst) — not product source
nimbalyst-local/
16 changes: 16 additions & 0 deletions stacklets/core/bot-runner/microbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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))
43 changes: 23 additions & 20 deletions tests/integration/test_room_modes_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -40,7 +42,6 @@

import time

import pytest
from nio import AsyncClient

from tests.integration.matrix import (
Expand All @@ -55,9 +56,6 @@
MARGE = mxid("marge")
EYES, CHECK = "👀", "✅"

pytestmark = [pytest.mark.unverified]


def _norm(key: str) -> str:
return (key or "").replace("\uFE0F", "").strip()

Expand Down Expand Up @@ -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.

Expand All @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions tests/stacklets/test_microbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──────────────────────────────────────────────────────────────

Expand Down
Loading