import asyncio import tempfile from datetime import UTC, datetime, timedelta from pathlib import Path from test_conversations import ScriptedClient, World, world from beaver_gateway.conversations.service import UserSaid from beaver_gateway.conversations.envelope import Envelope, RecallContext, render from beaver_gateway.conversations.texts import EnvelopeTexts from beaver_gateway.vault.watch import Change, VaultWatch, WatchRules __all__ = ["world"] HEADER = EnvelopeTexts().header 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() lines = text.splitlines() assert lines[0] == HEADER assert "(Warsaw)" in lines[1] assert lines[2].startswith("vault, changed since start: ") assert f"дни/{today}.md (+200)" in lines[2] assert "люди/Петя.md (+50)" in lines[2] assert sum(1 for line in lines if line.startswith("+ ")) == 31 assert "+ … 170 more" in lines assert len(lines) <= 120 append(diary, "- ещё одна\n") watch.note(diary) second = envelope.build() assert f"дни/{today}.md (+1)" 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", changes=changes, since=now - timedelta(hours=1), names_only=False, ) lines = text.splitlines() assert len(lines) <= 120 assert "… (envelope cap)" in lines assert lines[1] == "time: 2026-08-26 13:04 (Warsaw)" 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 rest.index("[injects") > rest.index(HEADER) branch = await world.conversations.spawn( kind="branch", parent=master, seed="brief", text="do X" ) await world.settle(branch, 1) assert "[envelope" not in ScriptedClient.instances[-1].prompts[0] await asyncio.sleep(0) async def test_recall_lines_follow_the_vault_block_and_reach_branches( world: World, ) -> None: root, watch = vault() seen: list[tuple[str, str]] = [] def recall(ctx: RecallContext) -> str | None: seen.append((ctx.kind, ctx.text)) return "👤 Прохор - карточка `люди/Прохор.md`" if "Прохор" in ctx.text else None noted: list[UserSaid] = [] world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=recall) # noqa: SLF001 world.conversations._user_sink = noted.append # noqa: SLF001 master = await world.conversations.create(kind="master", agent="a", origin="test") append(root / "люди" / "Прохор.md", "новое\n") watch.note(root / "люди" / "Прохор.md") await world.conversations.post(master, "что там у Прохор") await world.settle(master, 1) prompt = ScriptedClient.instances[0].prompts[0] head, _, rest = prompt.partition("\n\n") assert head == "что там у Прохор" lines = rest.splitlines() assert lines[0] == HEADER assert "люди/Прохор.md (+1)" in rest assert lines[-1] == "👤 Прохор - карточка `люди/Прохор.md`" assert rest.index("люди/Прохор.md (+1)") < rest.index("👤 Прохор") assert seen == [("master", "что там у Прохор")] assert [(m.kind, m.text) for m in noted] == [("master", "что там у Прохор")] branch = await world.conversations.spawn( kind="branch", parent=master, seed="clean", text="про Прохор подробнее" ) await world.settle(branch, 1) branch_prompt = ScriptedClient.instances[-1].prompts[0] assert "про Прохор подробнее\n\n" + HEADER in branch_prompt assert branch_prompt.endswith(f"{HEADER}\n👤 Прохор - карточка `люди/Прохор.md`") assert "vault, changed" not in branch_prompt # the first branch turn carries the seed line above the text assert seen[-1][0] == "branch" and seen[-1][1].endswith("про Прохор подробнее") assert noted[-1].kind == "branch" await world.conversations.post(master, "ничего про людей") await world.settle(master, 2) second = ScriptedClient.instances[0].prompts[1] assert "👤" not in second assert second.partition("\n\n")[2].startswith(HEADER) await asyncio.sleep(0) async def test_failing_recall_or_sink_never_blocks_the_turn(world: World) -> None: root, watch = vault() def boom(_: object) -> str: msg = "nope" raise RuntimeError(msg) world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=boom) # noqa: SLF001 world.conversations._user_sink = boom # noqa: SLF001 master = await world.conversations.create(kind="master", agent="a", origin="test") await world.conversations.post(master, "hello") await world.settle(master, 1) prompt = ScriptedClient.instances[0].prompts[0] assert prompt.startswith("hello\n\n" + HEADER) assert root.exists() await asyncio.sleep(0) async def test_the_recall_hook_and_the_sink_see_whose_turn_it_is(world: World) -> None: root, watch = vault() seen: list[RecallContext] = [] def recall(ctx: RecallContext) -> str | None: seen.append(ctx) return None if ctx.agent == "a" else "👤 указатель" noted: list[UserSaid] = [] world.conversations._envelope = Envelope(watch=watch, tz="UTC", recall=recall) # noqa: SLF001 world.conversations._user_sink = noted.append # noqa: SLF001 master = await world.conversations.create(kind="master", agent="a", origin="test") append(root / "люди" / "Прохор.md", "новое\n") watch.note(root / "люди" / "Прохор.md") await world.conversations.post(master, "что там у Прохор") await world.settle(master, 1) assert [ctx.agent for ctx in seen] == ["a"] assert [m.agent for m in noted] == ["a"] assert "👤" not in ScriptedClient.instances[0].prompts[0] await asyncio.sleep(0)