Files
beaver-gateway/tests/test_envelope.py
T

219 lines
8.9 KiB
Python

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.conversations import UserSaid
from beaver_gateway.core.envelope import HEADER, Envelope, RecallContext, 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()
lines = text.splitlines()
assert lines[0] == HEADER
assert "(Warsaw)" in lines[1]
assert lines[2].startswith("vault, изменено со старта: ")
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" 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 "… (потолок конверта)" in lines
assert lines[1] == "время: 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("[инжекты") > rest.index(HEADER)
branch = await world.conversations.spawn(
kind="branch", parent=master, seed="brief", text="do X"
)
await world.settle(branch, 1)
assert "[конверт" 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, изменено" 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)