refactor: split flat core into capability packages, layer the conversations service, English defaults for every model-facing text
This commit is contained in:
+6
-6
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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}]}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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:")
|
||||
|
||||
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
from beaver_gateway.core.transcript import (
|
||||
from beaver_gateway.backends.transcript import (
|
||||
CLI_VERSION,
|
||||
build_entries,
|
||||
messages_from_entries,
|
||||
|
||||
Reference in New Issue
Block a user