refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text

This commit is contained in:
hh
2026-09-02 00:13:20 +02:00
parent 253bde1b11
commit 9aaddbed75
77 changed files with 2987 additions and 2944 deletions
+6 -6
View File
@@ -18,11 +18,11 @@ from claude_agent_sdk import (
from httpx import ASGITransport, AsyncClient
from test_conversations import ScriptedClient, World
from beaver_gateway.core.conversation_store import rewrite_messages
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.scheduler import Job, JobRun, Scheduler
from beaver_gateway.core.transcript import build_entries
from beaver_gateway.frontends.markdown.history import rewrite_messages
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.app import McpRegistry
from beaver_gateway.jobs.scheduler import Job, JobRun, Scheduler
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.frontends.admin import AdminFrontend
from beaver_gateway.frontends.admin.frontend import build_app as build_admin
from beaver_gateway.frontends.api import ApiFrontend
@@ -473,7 +473,7 @@ async def test_close_distills_a_deep_chat(world: World) -> None:
assert body["digest"] == str(config.dir / "2026-08-29 - тема.md")
assert body["text"].count("\n") == 2
assert (await world.conversations.get(chat.external_id)).status == "closed"
assert (await world.conversations.queue.recent(master.id))[0].origin == "выжимка"
assert (await world.conversations.queue.recent(master.id))[0].origin == "digest"
again = await api.http.post(
f"/conversations/{chat.external_id}/close", headers=HEADERS
)
+3 -3
View File
@@ -7,8 +7,8 @@ import pytest
from fastapi import HTTPException
from starlette.requests import Request
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.frontends._auth import require_token
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.frontends.bearer import require_token
def _request(query: str = "", headers: dict[str, str] | None = None) -> Request:
@@ -61,7 +61,7 @@ async def test_bootstrap_entry_can_carry_a_scope() -> None:
def test_access_log_filter_masks_query_tokens() -> None:
from beaver_gateway.core.redact import RedactFilter
from beaver_gateway.security.redact import RedactFilter
record = logging.LogRecord(
"uvicorn.access",
+3 -3
View File
@@ -32,8 +32,8 @@ from beaver_gateway.backends.claude_sdk import (
UsageEvent,
fingerprint,
)
from beaver_gateway.core.transcript import messages_from_entries
from beaver_gateway.core.turn_capture import TurnCapture
from beaver_gateway.backends.transcript import messages_from_entries
from beaver_gateway.backends.capture import TurnCapture
def _stream(index: int, text: str) -> list[StreamEvent]:
@@ -506,7 +506,7 @@ async def test_deltas_reach_the_caller_before_the_turn_ends(cwd: Path) -> None:
async def test_policy_hook_denies_and_audits(cwd: Path) -> None:
from beaver_gateway.core.policy import Deny, ToolCall
from beaver_gateway.agents.policy import Deny, ToolCall
def no_days(c: ToolCall):
p = c.path()
@@ -1,7 +1,7 @@
import tempfile
from pathlib import Path
from beaver_gateway import config_loader
from beaver_gateway import config
def test_config_imports_sibling_modules():
@@ -12,5 +12,5 @@ def test_config_imports_sibling_modules():
"assert RULES == ('x',)\n"
"gateway = Gateway()\n"
)
gw = config_loader.load(root / "config.py")
gw = config.load(root / "config.py")
assert gw.agents == []
+23 -19
View File
@@ -21,12 +21,16 @@ from claude_agent_sdk import (
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.core.bus import EventBus
from beaver_gateway.core.conversations import Conversations, ConversationTexts, parse_at
from beaver_gateway.core.gateway_tools import SAY_IN_USER_TURN, _tools
from beaver_gateway.core.registry import AgentRegistry
from beaver_gateway.core.sessions import SessionPool
from beaver_gateway.core.transcript import (
from beaver_gateway.events.bus import EventBus
from beaver_gateway.conversations.service import (
Conversations,
ConversationTexts,
parse_at,
)
from beaver_gateway.conversations.tools import SAY_IN_USER_TURN, _tools
from beaver_gateway.app import AgentRegistry
from beaver_gateway.backends.sessions import SessionPool
from beaver_gateway.backends.transcript import (
build_entries,
close_open_tool_uses,
open_tool_uses,
@@ -312,14 +316,14 @@ async def test_urgent_interrupts_and_goes_first(world: World) -> None:
("urgent", "done"),
]
assert client.prompts[0] == "first"
assert client.prompts[1].startswith("[инжект: крон")
assert "прервал предыдущий тёрн" in client.prompts[1]
assert client.prompts[1].startswith("[inject: крон")
assert "cut the previous turn" in client.prompts[1]
assert client.prompts[1].endswith("ALERT")
assert client.prompts[2] == "second"
async def test_inject_header_is_configurable(world: World) -> None:
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.conversations.service import ConversationTexts
world.conversations._texts = ConversationTexts( # noqa: SLF001
inject_header=lambda ctx: (
@@ -343,7 +347,7 @@ async def test_normal_injects_ride_with_the_next_user_message(world: World) -> N
await world.settle(conv, 2)
prompts = ScriptedClient.instances[0].prompts
assert len(prompts) == 1
assert prompts[0].startswith("hello\n\n[инжекты")
assert prompts[0].startswith("hello\n\n[injects")
assert "- [watch] vault changed" in prompts[0]
@@ -373,7 +377,7 @@ async def test_spawn_seeds_first_user_message(world: World) -> None:
)
await world.settle(conv, 1)
prompt = ScriptedClient.instances[0].prompts[0]
assert prompt.startswith("[сид: brief] branch «t», ")
assert prompt.startswith("[seed: brief] branch «t», ")
assert prompt.endswith("\n\ndo X")
assert ScriptedClient.instances[0].options.system_prompt == "hi"
@@ -459,8 +463,8 @@ async def test_copy_seed_forks_parent_with_window(world: World) -> None:
await world.settle(child, 1)
assert ScriptedClient.instances[0].options.resume == child.session_id
prompt = ScriptedClient.instances[0].prompts[0]
assert prompt.startswith("[сид: copy] branch, ")
assert "последние 1 тёрнов" in prompt and prompt.endswith("\n\ngo")
assert prompt.startswith("[seed: copy] branch, ")
assert "last 1 turns" in prompt and prompt.endswith("\n\ngo")
assert (await world.conversations.get(child.external_id)).flags["seed"] is None
@@ -484,7 +488,7 @@ async def test_merge_injects_summary_into_parent(world: World) -> None:
assert (await world.conversations.get(branch.external_id)).status == "merged"
assert await world.statuses(master) == [("normal", "queued")]
item = (await world.conversations.queue.recent(master.id))[0]
assert item.origin == "слив" and item.text == result.text
assert item.origin == "merge" and item.text == result.text
async def test_recover_closes_open_tool_use_and_injects_interrupted(
@@ -538,7 +542,7 @@ async def test_recover_closes_open_tool_use_and_injects_interrupted(
assert tail["message"]["content"][0] == {
"type": "tool_result",
"tool_use_id": "t9",
"content": "прервано",
"content": "interrupted",
"is_error": True,
}
assert tail["parentUuid"] == entries[-2]["uuid"]
@@ -547,8 +551,8 @@ async def test_recover_closes_open_tool_use_and_injects_interrupted(
note = (await world.conversations.queue.recent(conv.id))[0]
assert (
"turn_dead" in note.text
and "оборван" in note.text
and "1 незакрытых" in note.text
and "cut by a gateway restart" in note.text
and "1 open tool calls" in note.text
)
await asyncio.sleep(0.2)
assert ScriptedClient.instances == []
@@ -798,9 +802,9 @@ async def test_wake_injects_start_a_turn_and_take_normals_along(world: World) ->
await world.settle(conv, 2)
prompts = ScriptedClient.instances[0].prompts
assert len(prompts) == 1
assert prompts[0].startswith("[инжект: schedule")
assert prompts[0].startswith("[inject: schedule")
assert "reminder" in prompts[0]
assert "[инжект: watch" in prompts[0]
assert "[inject: watch" in prompts[0]
assert "digest" in prompts[0]
assert await world.statuses(conv) == [("normal", "done"), ("wake", "done")]
+22 -22
View File
@@ -17,24 +17,24 @@ from test_conversations import ScriptedClient, World, world
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
from beaver_gateway.backends.claude_sdk import ClaudeSdkBackend
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.core.distill import (
from beaver_gateway.conversations.service import ConversationTexts
from beaver_gateway.conversations.distill import (
Distiller,
DistillContext,
LineCap,
check_digest,
trim_summary,
)
from beaver_gateway.core.gateway_tools import _tools, build_tool_server
from beaver_gateway.core.registry import AgentRegistry
from beaver_gateway.core.scheduler import Job, JobRun, Scheduler
from beaver_gateway.core.transcript import build_entries
from beaver_gateway.conversations.tools import _tools, build_tool_server
from beaver_gateway.app import AgentRegistry
from beaver_gateway.jobs.scheduler import Job, JobRun, Scheduler
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.storage.models import Conversation
__all__ = ["world"]
DIGEST = """---
type: выжимка
type: digest
source: "[[{chat}]]"
date: 2026-08-29
---
@@ -59,7 +59,7 @@ class DistillerClient(ScriptedClient):
async def receive_response(self):
prompt = self.prompts[-1]
written: list[ToolUseBlock] = []
if self.write and self.digest_dir is not None and "файл не пиши" not in prompt:
if self.write and self.digest_dir is not None and "write no file" not in prompt:
chat = prompt.split("«", 1)[1].split("»", 1)[0] if "«" in prompt else "чат"
path = self.digest_dir / "2026-08-29 - тема.md"
path.write_text(self.frontmatter.format(chat=chat), encoding="utf-8")
@@ -226,7 +226,7 @@ async def test_distill_writes_the_digest_indexes_it_and_merges_short(
assert result.digest.path == config.dir / "2026-08-29 - тема.md"
assert result.digest.source == "[[2026-08-20 - тема чата]]"
index = config.index.read_text(encoding="utf-8")
assert index.startswith("# индекс")
assert index.startswith("# index")
assert "- 2026-08-29 [[2026-08-20 - тема чата]] → [[2026-08-29 - тема]]" in index
assert result.text.count("\n") == 2 and not result.trimmed
closed = await world.conversations.get(chat.external_id)
@@ -236,9 +236,9 @@ async def test_distill_writes_the_digest_indexes_it_and_merges_short(
fork = await world.conversations.get(result.fork.external_id)
assert fork.kind == "fork" and fork.agent_name == "x" and fork.status == "closed"
items = await world.conversations.queue.recent(master.id)
assert items[0].origin == "выжимка"
assert items[0].origin == "digest"
assert items[0].text.startswith(
"Закрыт глубокий чат [[2026-08-20 - тема чата]], выжимка [[2026-08-29 - тема]]."
"Deep chat [[2026-08-20 - тема чата]] closed, digest [[2026-08-29 - тема]]."
)
assert items[0].text.endswith(result.text)
forked = ScriptedClient.instances[-1]
@@ -270,8 +270,8 @@ async def test_memory_off_merges_without_a_file(world: World) -> None:
assert (await world.conversations.get(chat.external_id)).status == "closed"
items = await world.conversations.queue.recent(master.id)
assert len(items) == 1 and items[0].text.endswith(result.text)
assert ", выжимка" not in items[0].text
assert "файл не пиши" in ScriptedClient.instances[-1].prompts[0]
assert ", digest" not in items[0].text
assert "write no file" in ScriptedClient.instances[-1].prompts[0]
async def test_bad_frontmatter_and_long_merge_are_reported(world: World) -> None:
@@ -294,13 +294,13 @@ async def test_bad_frontmatter_and_long_merge_are_reported(world: World) -> None
def test_check_digest_rejects_what_is_not_a_digest(tmp_path: Path) -> None:
config = Distiller(agent="x", dir=tmp_path, index=tmp_path / "i.md")
path = tmp_path / "d.md"
path.write_text("---\ntype: выжимка\nsource: ''\ndate: 2026-08-29\n---\nx\n")
assert check_digest(path, config) == "`source` пустой"
path.write_text("---\ntype: выжимка\nsource: '[[a]]'\ndate: вчера\n---\nx\n")
path.write_text("---\ntype: digest\nsource: ''\ndate: 2026-08-29\n---\nx\n")
assert check_digest(path, config) == "`source` is empty"
path.write_text("---\ntype: digest\nsource: '[[a]]'\ndate: вчера\n---\nx\n")
assert "`date`" in check_digest(path, config)
path.write_text("---\ntype: выжимка\nsource: '[[a]]'\ndate: 2026-08-29\n---\n\n")
assert check_digest(path, config) == "тело пустое"
path.write_text("---\ntype: выжимка\nsource: '[[a]]'\ndate: 2026-08-29\n---\nx\n")
path.write_text("---\ntype: digest\nsource: '[[a]]'\ndate: 2026-08-29\n---\n\n")
assert check_digest(path, config) == "empty body"
path.write_text("---\ntype: digest\nsource: '[[a]]'\ndate: 2026-08-29\n---\nx\n")
digest = check_digest(path, config)
assert not isinstance(digest, str) and digest.date.isoformat() == "2026-08-29"
assert trim_summary("a\n\nb\nc\nd\ne\nf") == ("a\nb\nc\nd\ne", True)
@@ -347,7 +347,7 @@ async def test_close_chat_tool_closes_after_the_reply(world: World) -> None:
assert row.flags["closed_reason"] == "close_chat"
assert row.flags["digest"] is not None
items = await world.conversations.queue.recent(master.id)
assert items[0].origin == "выжимка"
assert items[0].origin == "digest"
async def test_idle_picks_quiet_chats_after_launch_at_most_limit(world: World) -> None:
@@ -412,8 +412,8 @@ async def test_line_cap_bounces_a_long_rewrite_and_asks_to_shorten(
client = ScriptedClient.instances[-1]
assert len(client.prompts) == 2
assert "70 строк при потолке 60" in client.prompts[1]
assert "[инжект: потолок" in client.prompts[1]
assert "70 lines against a cap of 60" in client.prompts[1]
assert "[inject: cap" in client.prompts[1]
assert state.read_text(encoding="utf-8").count("\n") == 10 - 1
row = await world.conversations.get(job.external_id)
assert row.flags["line_cap_attempts"] == 1
+13 -10
View File
@@ -5,12 +5,15 @@ 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
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=("дни/*", "люди/*", "мета/бобер/*"),
@@ -88,11 +91,11 @@ def test_envelope_respects_ceilings_and_names_only_window() -> None:
lines = text.splitlines()
assert lines[0] == HEADER
assert "(Warsaw)" in lines[1]
assert lines[2].startswith("vault, изменено со старта: ")
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" in lines
assert "+ … 170 more" in lines
assert len(lines) <= 120
append(diary, "- ещё одна\n")
watch.note(diary)
@@ -122,8 +125,8 @@ def test_render_hits_total_ceiling() -> None:
)
lines = text.splitlines()
assert len(lines) <= 120
assert "… (потолок конверта)" in lines
assert lines[1] == "время: 2026-08-26 13:04 (Warsaw)"
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(
@@ -142,12 +145,12 @@ async def test_master_turn_gets_envelope_after_text_and_before_injects(
assert head == "hello"
assert rest.startswith(HEADER)
assert "люди/Прохор.md (+1)" in rest
assert rest.index("[инжекты") > rest.index(HEADER)
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 "[конверт" not in ScriptedClient.instances[-1].prompts[0]
assert "[envelope" not in ScriptedClient.instances[-1].prompts[0]
await asyncio.sleep(0)
@@ -187,7 +190,7 @@ async def test_recall_lines_follow_the_vault_block_and_reach_branches(
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
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"
+3 -3
View File
@@ -5,8 +5,8 @@ import pytest
from fastmcp import FastMCP
from fastmcp.tools.base import ToolResult
from beaver_gateway.core import redact as redact_mod
from beaver_gateway.core.redact import redact
from beaver_gateway.security import redact as redact_mod
from beaver_gateway.security.redact import redact
from beaver_gateway.mcp.internal_app import build_internal_app
from beaver_gateway.mcp.redacting import RedactingMiddleware
from beaver_gateway.mcp.types import McpServer
@@ -179,7 +179,7 @@ async def test_gateway_own_tools_are_filtered_too() -> None:
# in-process — so they carry their own wrapper.
from claude_agent_sdk import SdkMcpTool
from beaver_gateway.core.gateway_tools import _redacting
from beaver_gateway.conversations.tools import _redacting
async def handler(_args: dict[str, object]) -> dict[str, object]:
return {"content": [{"type": "text", "text": KOMODO_DEPLOY}]}
+1 -1
View File
@@ -2,7 +2,7 @@ from pathlib import Path
import pytest
from beaver_gateway.core.policy import Deny, ToolCall, brief, evaluate, hook_output
from beaver_gateway.agents.policy import Deny, ToolCall, brief, evaluate, hook_output
def call(tool: str, **tool_input) -> ToolCall:
+2 -2
View File
@@ -5,8 +5,8 @@ import logging
import pytest
from beaver_gateway.core import redact as redact_mod
from beaver_gateway.core.redact import (
from beaver_gateway.security import redact as redact_mod
from beaver_gateway.security.redact import (
RedactFilter,
RedactingFormatter,
env_secrets,
+17 -13
View File
@@ -3,8 +3,12 @@ from datetime import UTC, date, datetime, timedelta
from test_conversations import ScriptedClient, StubFrontend, World, world
from beaver_gateway.core.conversations import ConversationTexts
from beaver_gateway.core.rotation import HandoutContext, Rotation, RotationPolicy
from beaver_gateway.conversations.service import ConversationTexts
from beaver_gateway.conversations.rotation import (
HandoutContext,
Rotation,
RotationPolicy,
)
from beaver_gateway.storage.models import Conversation, Usage
__all__ = ["world"]
@@ -28,7 +32,7 @@ def master(
def test_night_rule_needs_silence_and_a_master_from_before_four() -> None:
now = datetime(2026, 8, 29, 2, 30, tzinfo=UTC)
quiet = master(created_ago=timedelta(hours=20), silence=timedelta(hours=4), now=now)
assert POLICY.reason(quiet, now=now, context_tokens=0) == "ночь"
assert POLICY.reason(quiet, now=now, context_tokens=0) == "night"
active = master(
created_ago=timedelta(hours=20), silence=timedelta(minutes=5), now=now
)
@@ -49,13 +53,13 @@ def test_age_and_context_rules_need_thirty_minutes_of_silence() -> None:
old = master(
created_ago=timedelta(hours=37), silence=timedelta(minutes=31), now=now
)
assert POLICY.reason(old, now=now, context_tokens=0) == "возраст"
assert POLICY.reason(old, now=now, context_tokens=0) == "age"
busy = master(
created_ago=timedelta(hours=37), silence=timedelta(minutes=5), now=now
)
assert POLICY.reason(busy, now=now, context_tokens=0) is None
big = master(created_ago=timedelta(hours=2), silence=timedelta(minutes=31), now=now)
assert POLICY.reason(big, now=now, context_tokens=90_000) == "транскрипт"
assert POLICY.reason(big, now=now, context_tokens=90_000) == "context"
assert POLICY.reason(big, now=now, context_tokens=70_000) is None
@@ -73,7 +77,7 @@ async def test_rotation_does_not_touch_a_master_mid_turn(world: World) -> None:
await world.conversations.post(conv, "working")
await asyncio.sleep(0.2)
rotation = Rotation(world.conversations, POLICY)
assert await rotation.rotate(conv, "возраст") is None
assert await rotation.rotate(conv, "age") is None
assert (await world.conversations.get(conv.external_id)).status == "open"
assert len(await world.conversations.find(kind="master")) == 1
ScriptedClient.hold.set()
@@ -119,7 +123,7 @@ async def test_rotation_order_handout_close_marks_moves_and_new_day(
await world.conversations.inject(old, "later", urgency="normal", origin="крон")
old_client = ScriptedClient.instances[0]
new = await Rotation(world.conversations, POLICY).rotate(old, "ночь")
new = await Rotation(world.conversations, POLICY).rotate(old, "night")
assert new is not None and new.kind == "master"
assert handouts[0].day == date(2026, 8, 27)
assert old_client.prompts[-1] == "напиши хендаут за 2026-08-27"
@@ -138,10 +142,10 @@ async def test_rotation_order_handout_close_marks_moves_and_new_day(
await world.settle(new, 1)
new_client = ScriptedClient.instances[-1]
prompt = new_client.prompts[0]
assert prompt.startswith("[сид: morning] master")
assert "[инжект: ротация" in prompt
assert "Новый день 20" in prompt and "(ночь)" in prompt
assert "переехало из старого мастера: 1" in prompt
assert prompt.startswith("[seed: morning] master")
assert "[inject: rotation" in prompt
assert "Новый день 20" in prompt and "(night)" in prompt
assert "1 queued injects moved over" in prompt
moved = await world.conversations.queue.pending(new.id)
assert [(i.priority, i.text) for i in moved] == [("normal", "later")]
assert (await world.conversations.find(kind="master", status="open")) == [
@@ -171,11 +175,11 @@ async def test_due_uses_last_usage_row_for_context_size(world: World) -> None:
assert await rotation.due() == []
rotation = Rotation(world.conversations, RotationPolicy(max_context_tokens=5))
(pair,) = await rotation.due()
assert pair[0].id == conv.id and pair[1] == "транскрипт"
assert pair[0].id == conv.id and pair[1] == "context"
def test_context_of_prefers_last_call_and_averages_old_rows() -> None:
from beaver_gateway.core.conversations import context_of
from beaver_gateway.conversations.service import context_of
from beaver_gateway.storage.models import Usage
fresh = Usage(
+6 -6
View File
@@ -7,10 +7,10 @@ import frontmatter
import httpx
import pytest
from beaver_gateway.core.auth import TokenStore
from beaver_gateway.core.conversation_store import load_messages, rewrite_messages
from beaver_gateway.core.gateway_tools import _tools
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.security.auth import TokenStore
from beaver_gateway.frontends.markdown.history import load_messages, rewrite_messages
from beaver_gateway.conversations.tools import _tools
from beaver_gateway.app import McpRegistry
from beaver_gateway.frontends.anthropic import AnthropicMessagesFrontend
from beaver_gateway.frontends.api import ApiFrontend
from beaver_gateway.frontends.base import GatewayRuntime
@@ -127,10 +127,10 @@ async def test_api_spawn_deep_lands_in_vault(stack: Stack) -> None:
)
assert r.status_code == 400
path = stack.vault / rel
text = await stack.wait_file(path, "ok:[сид: brief]")
text = await stack.wait_file(path, "ok:[seed: brief]")
post = frontmatter.loads(text)
assert post.metadata == {"agent": "d", "conversation_id": body["id"]}
assert post.content.startswith("### User:\n\n[сид: brief] deep «Тема», ")
assert post.content.startswith("### User:\n\n[seed: brief] deep «Тема», ")
assert post.content.rstrip().endswith("### User:")
+3 -3
View File
@@ -13,8 +13,8 @@ from httpx import ASGITransport, AsyncClient
from pgqueuer import PsycopgDriver, Queries
from test_conversations import World
from beaver_gateway.core.conversations import parse_at
from beaver_gateway.core.scheduler import Budget, Job, JobRun, Scheduler, next_run
from beaver_gateway.conversations.service import parse_at
from beaver_gateway.jobs.scheduler import Budget, Job, JobRun, Scheduler, next_run
from beaver_gateway.storage.models import RateLimit
DATABASE_URL = os.environ.get("TEST_DATABASE_URL")
@@ -165,7 +165,7 @@ async def test_schedule_survives_a_restart(world: World, pg: Pg) -> None:
world.conversations._normal_window = 0.05 # noqa: SLF001
await until(lambda: len(ScriptedClient_prompts(world)) == 1)
prompt = ScriptedClient_prompts(world)[0]
assert prompt.startswith("[инжект: schedule")
assert prompt.startswith("[inject: schedule")
assert prompt.endswith("push X")
assert await world.statuses(conv) == [("wake", "done")]
assert await world.conversations.schedules(conv) == []
+9 -9
View File
@@ -10,8 +10,8 @@ from aiogram.methods import SendMessage
from aiogram.types import Update
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
from beaver_gateway.core.registry import McpRegistry
from beaver_gateway.core.transcript import build_entries
from beaver_gateway.app import McpRegistry
from beaver_gateway.backends.transcript import build_entries
from beaver_gateway.frontends.base import GatewayRuntime
from beaver_gateway.frontends.telegram import Attachments, TelegramFrontend
from beaver_gateway.frontends.telegram.render import (
@@ -279,7 +279,7 @@ async def stack() -> Stack:
async def test_general_is_master_and_reply_has_no_thread(stack: Stack) -> None:
stack.bot.message("hi")
reply = await stack.until(lambda: stack.sent_with("hi"), what="reply")
assert reply["text"].startswith("ok:[сид: clean] master")
assert reply["text"].startswith("ok:[seed: clean] master")
assert reply["thread"] == 901
assert reply["parse_mode"] == "HTML"
assert stack.bot.topics == ["🦫 General"]
@@ -316,8 +316,8 @@ async def test_new_topic_becomes_morning_branch_and_replies_in_thread(
)
assert branch.parent_id == master.id
prompts = [p for c in ScriptedClient.instances for p in c.prompts]
seed = next(p for p in prompts if p.startswith("[сид: morning] branch «план»"))
assert "Хендаут не приехал." in seed
seed = next(p for p in prompts if p.startswith("[seed: morning] branch «план»"))
assert "No handout arrived." in seed
assert seed.endswith("hello topic")
assert stack.bot.drafts[-1]["message_thread_id"] == 7
@@ -334,7 +334,7 @@ async def test_first_message_without_service_message_seeds_with_text(
client = next(
c for c in ScriptedClient.instances if c.prompts and "сразу" in c.prompts[0]
)
assert client.prompts[0].startswith("[сид: morning]")
assert client.prompts[0].startswith("[seed: morning]")
assert client.prompts[0].endswith("сразу текстом")
assert len(client.prompts) == 1
@@ -432,7 +432,7 @@ async def test_question_becomes_buttons_and_callback_answers(stack: Stack) -> No
stack.bot.callback(f"q:{pending[0]}:0:1", question["message_id"])
assert await asyncio.wait_for(asking, 5) == "Красный"
assert stack.world.conversations.answer_text("Красный") == (
"Пользователь ответил: Красный"
"The user answered: Красный"
)
await stack.until(
lambda: any("✅ Красный" in e.get("text", "") for e in stack.bot.edits),
@@ -454,7 +454,7 @@ async def test_question_timeout_renders_text_and_free_text_answers(
payload = {"questions": [{"header": "Q", "question": "Сколько?", "options": []}]}
result = await stack.world.conversations.ask(master.external_id, payload)
assert result is None
assert "не ответил" in stack.world.conversations.answer_text(None)
assert "did not answer" in stack.world.conversations.answer_text(None)
await stack.until(
lambda: any("время вышло" in e.get("text", "") for e in stack.bot.edits),
what="timeout edit",
@@ -510,7 +510,7 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None:
stack.bot.message("первое в новый топик", thread=902)
reply = await stack.until(lambda: stack.sent_with("первое в новый топик"), what="r")
assert reply["thread"] == 902
assert reply["text"].startswith("ok:[сид: morning] branch «отчёт»")
assert reply["text"].startswith("ok:[seed: morning] branch «отчёт»")
assert await stack.tg.mark_topic(child)
assert stack.bot.topics[-1] == "edit:902:✅ отчёт"
+1 -1
View File
@@ -1,4 +1,4 @@
from beaver_gateway.core.transcript import (
from beaver_gateway.backends.transcript import (
CLI_VERSION,
build_entries,
messages_from_entries,