import asyncio import tempfile from datetime import UTC, datetime, timedelta from pathlib import Path from test_conversations import ScriptedClient, World, world from beaver_gateway.core.envelope import HEADER, Envelope, render from beaver_gateway.core.watch import Change, VaultWatch, WatchRules __all__ = ["world"] RULES = WatchRules( full=("дни/{today}.md",), names=("дни/*", "люди/*", "мета/бобер/*"), ignore=("чаты/*", "мета/*"), ) def vault() -> tuple[Path, VaultWatch]: root = Path(tempfile.mkdtemp(prefix="beaver-vault-")) for sub in ("дни", "люди", "чаты", "мета/бобер", "мета/чужое"): (root / sub).mkdir(parents=True) today = datetime.now(UTC).date().isoformat() (root / "дни" / f"{today}.md").write_text("# день\n- 09:00 проснулся\n") (root / "люди" / "Прохор.md").write_text("# Прохор\nстрока\n") (root / "чаты" / "чат.md").write_text("чат\n") (root / "мета" / "чужое" / "x.md").write_text("x\n") watch = VaultWatch(root, RULES, tz="UTC") watch.snapshot() return root, watch def append(path: Path, text: str) -> None: with path.open("a", encoding="utf-8") as fh: fh.write(text) def test_rules_classify_paths() -> None: today = "2026-08-29" assert RULES.kind("дни/2026-08-29.md", today=today) == "full" assert RULES.kind("дни/2026-08-28.md", today=today) == "names" assert RULES.kind("люди/Прохор.md", today=today) == "names" assert RULES.kind("мета/бобер/состояние.md", today=today) == "names" assert RULES.kind("мета/чужое/x.md", today=today) is None assert RULES.kind("чаты/чат.md", today=today) is None assert RULES.kind("люди/фото.png", today=today) is None def test_watch_reports_only_added_lines_with_mtime() -> None: root, watch = vault() today = watch.today() diary = root / "дни" / f"{today}.md" append(diary, "- 12:40 вышел\n- 13:00 кофе\n") append(root / "люди" / "Прохор.md", "ещё\n") append(root / "чаты" / "чат.md", "ignored\n") for path in (diary, root / "люди" / "Прохор.md", root / "чаты" / "чат.md"): watch.note(path) changes = {c.path: c for c in watch.take()} assert set(changes) == {f"дни/{today}.md", "люди/Прохор.md"} assert changes[f"дни/{today}.md"].added == ["- 12:40 вышел", "- 13:00 кофе"] assert changes[f"дни/{today}.md"].full assert changes["люди/Прохор.md"].added_count == 1 assert changes["люди/Прохор.md"].added == [] assert abs( changes[f"дни/{today}.md"].mtime - datetime.fromtimestamp(diary.stat().st_mtime, tz=UTC) ) < timedelta(seconds=1) assert watch.take() == [] append(diary, "- 14:00 снова\n") watch.note(diary) (only,) = watch.take() assert only.added == ["- 14:00 снова"] def test_envelope_respects_ceilings_and_names_only_window() -> None: root, watch = vault() today = watch.today() diary = root / "дни" / f"{today}.md" append(diary, "".join(f"- строка {i}\n" for i in range(200))) for name in ("Петя", "Маша"): (root / "люди" / f"{name}.md").write_text("новый\n" * 50) watch.note(root / "люди" / f"{name}.md") watch.note(diary) envelope = Envelope(watch=watch, tz="Europe/Warsaw") text = envelope.build(streak=7, injects=2) lines = text.splitlines() assert lines[0] == HEADER assert "(Warsaw)" in lines[1] assert lines[2] == "мастер: 7 реплик подряд без ветки" assert f"дни/{today}.md (+200)" in lines[3] assert "люди/Петя.md (+50)" in lines[3] assert "инжекты со старта: (2) ниже" in lines[4] assert sum(1 for line in lines if line.startswith("+ ")) == 31 assert "+ … ещё 170" in lines assert len(lines) <= 120 append(diary, "- ещё одна\n") watch.note(diary) second = envelope.build(streak=8, injects=0) assert f"дни/{today}.md (+1)" in second assert "+ - ещё одна" not in second assert "инжекты" not in second def test_render_hits_total_ceiling() -> None: now = datetime(2026, 8, 26, 11, 4, tzinfo=UTC) changes = [ Change( path=f"дни/{i}.md", mtime=now, added=[f"l{j}" for j in range(30)], added_count=30, full=True, ) for i in range(10) ] text = render( now=now, tz="Europe/Warsaw", streak=1, changes=changes, since=now - timedelta(hours=1), names_only=False, injects=0, ) lines = text.splitlines() assert len(lines) <= 120 assert "… (потолок конверта)" in lines assert lines[1] == "время: 2026-08-26 13:04 (Warsaw)" assert lines[2] == "мастер: 1 реплика подряд без ветки" async def test_master_turn_gets_envelope_after_text_and_before_injects( world: World, ) -> None: root, watch = vault() world.conversations._envelope = Envelope(watch=watch, tz="UTC") # noqa: SLF001 master = await world.conversations.create(kind="master", agent="a", origin="test") append(root / "люди" / "Прохор.md", "новое\n") watch.note(root / "люди" / "Прохор.md") await world.conversations.inject(master, "tick", urgency="normal", origin="крон") await world.conversations.post(master, "hello") await world.settle(master, 2) prompt = ScriptedClient.instances[0].prompts[0] head, _, rest = prompt.partition("\n\n") assert head == "hello" assert rest.startswith(HEADER) assert "люди/Прохор.md (+1)" in rest assert "мастер: 0 реплик подряд без ветки" in rest assert rest.index("[инжекты") > rest.index(HEADER) assert (await world.conversations.get(master.external_id)).flags["streak"] == 1 branch = await world.conversations.spawn( kind="branch", parent=master, seed="brief", text="do X" ) await world.settle(branch, 1) assert (await world.conversations.get(master.external_id)).flags["streak"] == 0 assert "[конверт" not in ScriptedClient.instances[-1].prompts[0] await asyncio.sleep(0)