feat(voice,agents,memory): voice points get their own branch, agent and keys
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from beaver_gateway.app import AgentRegistry, McpRegistry
|
||||
from beaver_gateway.agents.claude import ClaudeAgent, ClaudeOptions
|
||||
from beaver_gateway.conversations.envelope import RecallContext
|
||||
from beaver_gateway.events.stream import (
|
||||
build_content_block_stop,
|
||||
build_message_delta,
|
||||
build_message_start,
|
||||
build_message_stop,
|
||||
build_text_block_start,
|
||||
build_text_delta,
|
||||
)
|
||||
from beaver_gateway.conversations.texts import UserSaid
|
||||
from beaver_gateway.frontends.base import GatewayRuntime
|
||||
from beaver_gateway.security.auth import TokenStore, scopes_with
|
||||
|
||||
from beaver_agent.memory.recall import Recall, ReplyLog
|
||||
from beaver_agent.voice import AGENT, KEY, TITLE, VoiceFrontend, rotate
|
||||
from beaver_agent.voice.texts import ROTATED
|
||||
|
||||
VOICE = {"Authorization": "Bearer voice-key"}
|
||||
OPS = {"Authorization": "Bearer ops-key"}
|
||||
ASK = {
|
||||
"room": "andreii",
|
||||
"device": "esp32-1",
|
||||
"request_id": "3fd0d6ed-a81b-489c-a1d2-3b97ba84892b",
|
||||
"content": "ало клод как дела",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Conv:
|
||||
external_id: str = "conv-1"
|
||||
agent_name: str = AGENT
|
||||
kind: str = "branch"
|
||||
title: str = TITLE
|
||||
status: str = "open"
|
||||
last_user_activity_at: datetime | None = None
|
||||
flags: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Binding:
|
||||
frontend: str
|
||||
external_id: str
|
||||
visible: bool = True
|
||||
|
||||
|
||||
class FakeConversations:
|
||||
"""Ровно те методы сервиса, которыми пользуется голосовое окно."""
|
||||
|
||||
def __init__(self, bound: Conv | None = None) -> None:
|
||||
self.conv = bound
|
||||
self.bindings_of: dict[str, list[Binding]] = {}
|
||||
self.said: list[tuple[str, str]] = []
|
||||
self.prompts: list[str] = []
|
||||
self.bound: list[tuple[str, str, str]] = []
|
||||
self.closed: list[str] = []
|
||||
self.spawned: list[dict[str, Any]] = []
|
||||
self.reply = "живу, слушаю"
|
||||
self.spawn_hook = None
|
||||
|
||||
async def find_bound(self, *, frontend: str, external_id: str) -> Conv | None:
|
||||
if self.conv is None or frontend != "voice" or external_id != KEY:
|
||||
return None
|
||||
return self.conv
|
||||
|
||||
async def spawn(self, **kwargs: Any) -> Conv:
|
||||
self.spawned.append(kwargs)
|
||||
conv = Conv(external_id=f"conv-{len(self.spawned) + 1}")
|
||||
self.conv = conv
|
||||
binding = kwargs.get("binding")
|
||||
if binding:
|
||||
self.bindings_of[conv.external_id] = [Binding(*binding)]
|
||||
return conv
|
||||
|
||||
async def bind(self, conv: Conv, *, frontend: str, external_id: str) -> None:
|
||||
self.bound.append((conv.external_id, frontend, external_id))
|
||||
|
||||
async def bindings(self, conv: Conv) -> list[Binding]:
|
||||
return self.bindings_of.get(conv.external_id, [])
|
||||
|
||||
async def close(self, conv: Conv) -> None:
|
||||
conv.status = "closed"
|
||||
self.closed.append(conv.external_id)
|
||||
|
||||
async def say(self, conv: Conv, text: str) -> None:
|
||||
self.said.append((conv.external_id, text))
|
||||
|
||||
async def pending_seed(self, conv: Conv) -> str | None:
|
||||
return conv.flags.pop("seed", None)
|
||||
|
||||
async def run_text_turn(self, conv: Conv, prompt: str, **_: Any) -> tuple[str, Any]:
|
||||
self.prompts.append(prompt)
|
||||
return self.reply, None
|
||||
|
||||
async def turn(self, conv: Conv, *, messages: list[Any], **_: Any) -> Any:
|
||||
self.prompts.append(messages[0]["content"])
|
||||
yield build_message_start(message_id="m", model="haiku")
|
||||
yield build_text_block_start(0)
|
||||
for chunk in ("живу, ", "слушаю"):
|
||||
yield build_text_delta(0, chunk)
|
||||
yield build_content_block_stop(0)
|
||||
yield build_message_delta(stop_reason="end_turn")
|
||||
yield build_message_stop()
|
||||
|
||||
|
||||
class Bus:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def publish(self, type_: str, **data: Any) -> dict[str, Any]:
|
||||
event = {"type": type_, **data}
|
||||
self.events.append(event)
|
||||
return event
|
||||
|
||||
|
||||
def stand(conversations: FakeConversations) -> tuple[VoiceFrontend, GatewayRuntime]:
|
||||
agent = ClaudeAgent(
|
||||
name=AGENT,
|
||||
model="claude-haiku-4-5-20251001",
|
||||
cwd=".",
|
||||
system_prompt="голос",
|
||||
kinds=("branch",),
|
||||
options=ClaudeOptions(tools=()),
|
||||
)
|
||||
frontend = VoiceFrontend(agent=AGENT)
|
||||
runtime = GatewayRuntime(
|
||||
agents=AgentRegistry([agent]),
|
||||
mcps=McpRegistry([]),
|
||||
backends={},
|
||||
token_store=TokenStore(
|
||||
bootstrap={"voice": "voice-key", "ops": "ops-key"},
|
||||
bootstrap_scopes={"voice": "voice", "ops": "api"},
|
||||
),
|
||||
db=cast("Any", None),
|
||||
frontends=(frontend,),
|
||||
conversations=conversations,
|
||||
bus=Bus(),
|
||||
scopes=scopes_with(["voice"]),
|
||||
)
|
||||
frontend.configure(runtime)
|
||||
return frontend, runtime
|
||||
|
||||
|
||||
def client(frontend: VoiceFrontend) -> httpx.AsyncClient:
|
||||
return httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=cast("Any", frontend.app())),
|
||||
base_url="http://t",
|
||||
)
|
||||
|
||||
|
||||
async def test_the_payload_is_answered_and_its_fields_come_back() -> None:
|
||||
conversations = FakeConversations()
|
||||
frontend, _ = stand(conversations)
|
||||
async with client(frontend) as http:
|
||||
response = await http.post("/", json=ASK, headers=VOICE)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"request_id": ASK["request_id"],
|
||||
"room": "andreii",
|
||||
"device": "esp32-1",
|
||||
"reply": "живу, слушаю",
|
||||
"conversation": "conv-2",
|
||||
}
|
||||
assert conversations.prompts == ["[andreii · esp32-1] ало клод как дела"]
|
||||
|
||||
|
||||
async def test_the_first_ask_opens_the_branch_and_the_next_one_keeps_it() -> None:
|
||||
conversations = FakeConversations()
|
||||
frontend, _ = stand(conversations)
|
||||
async with client(frontend) as http:
|
||||
first = (await http.post("/", json=ASK, headers=VOICE)).json()
|
||||
again = (
|
||||
await http.post("/", json={**ASK, "content": "а время?"}, headers=VOICE)
|
||||
).json()
|
||||
assert first["conversation"] == again["conversation"]
|
||||
assert len(conversations.spawned) == 1
|
||||
spawn = conversations.spawned[0]
|
||||
assert spawn["kind"] == "branch"
|
||||
assert spawn["agent"] == AGENT
|
||||
assert spawn["title"] == TITLE
|
||||
assert spawn["binding"] is None
|
||||
assert conversations.bound == [(first["conversation"], "voice", KEY)]
|
||||
|
||||
|
||||
async def test_the_question_lands_in_the_branch_first() -> None:
|
||||
conversations = FakeConversations()
|
||||
frontend, _ = stand(conversations)
|
||||
async with client(frontend) as http:
|
||||
await http.post("/", json=ASK, headers=VOICE)
|
||||
assert conversations.said == [("conv-2", "🎤 andreii · esp32-1\nало клод как дела")]
|
||||
|
||||
|
||||
async def test_two_rooms_at_once_open_one_branch() -> None:
|
||||
conversations = FakeConversations()
|
||||
frontend, _ = stand(conversations)
|
||||
async with client(frontend) as http:
|
||||
await asyncio.gather(
|
||||
http.post("/", json=ASK, headers=VOICE),
|
||||
http.post("/", json={**ASK, "room": "кухня"}, headers=VOICE),
|
||||
)
|
||||
assert len(conversations.spawned) == 1
|
||||
|
||||
|
||||
async def test_only_a_voice_key_opens_the_route() -> None:
|
||||
frontend, _ = stand(FakeConversations())
|
||||
async with client(frontend) as http:
|
||||
assert (await http.post("/", json=ASK)).status_code == 401
|
||||
assert (await http.post("/", json=ASK, headers=OPS)).status_code == 403
|
||||
|
||||
|
||||
async def test_a_body_without_the_words_is_a_400() -> None:
|
||||
frontend, _ = stand(FakeConversations())
|
||||
async with client(frontend) as http:
|
||||
for missing in ("room", "device", "content"):
|
||||
body = {k: v for k, v in ASK.items() if k != missing}
|
||||
response = await http.post("/", json=body, headers=VOICE)
|
||||
assert response.status_code == 400
|
||||
assert missing in response.json()["error"]
|
||||
|
||||
|
||||
async def test_the_night_keeps_the_topic_and_starts_the_branch_over() -> None:
|
||||
old = Conv(last_user_activity_at=datetime.now(UTC))
|
||||
conversations = FakeConversations(bound=old)
|
||||
conversations.bindings_of["conv-1"] = [Binding("telegram", "42")]
|
||||
new = await rotate(cast("Any", conversations))
|
||||
assert new is not None
|
||||
assert conversations.spawned[0]["binding"] == ("telegram", "42")
|
||||
assert conversations.bound == [(new.external_id, "voice", KEY)]
|
||||
assert conversations.closed == ["conv-1"]
|
||||
assert conversations.said == [(new.external_id, ROTATED)]
|
||||
|
||||
|
||||
async def test_a_branch_nobody_spoke_in_is_left_alone() -> None:
|
||||
conversations = FakeConversations(bound=Conv())
|
||||
assert await rotate(cast("Any", conversations)) is None
|
||||
assert conversations.spawned == []
|
||||
|
||||
|
||||
def test_the_voice_agent_gets_no_recall_and_no_reply_log(tmp_path) -> None:
|
||||
recall = Recall(
|
||||
vault=tmp_path,
|
||||
people_dir=tmp_path / "люди",
|
||||
notes_dir=tmp_path / "записки",
|
||||
diary_dir=tmp_path / "дни",
|
||||
boards_dir=tmp_path / "доски",
|
||||
mute=frozenset({AGENT}),
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
assert (
|
||||
recall.block(RecallContext(text="Прохор", kind="branch", now=now, agent=AGENT))
|
||||
is None
|
||||
)
|
||||
log = ReplyLog(tmp_path / "реплики", mute=frozenset({AGENT}))
|
||||
log.write(
|
||||
UserSaid(
|
||||
conversation_id="c",
|
||||
kind="branch",
|
||||
title=TITLE,
|
||||
text="что там у Прохор",
|
||||
at=now,
|
||||
agent=AGENT,
|
||||
)
|
||||
)
|
||||
assert not (tmp_path / "реплики").exists()
|
||||
|
||||
|
||||
async def test_a_streaming_ask_gets_the_answer_in_pieces() -> None:
|
||||
conversations = FakeConversations()
|
||||
frontend, _ = stand(conversations)
|
||||
frames: list[tuple[str, dict[str, Any]]] = []
|
||||
async with (
|
||||
client(frontend) as http,
|
||||
http.stream("POST", "/", json={**ASK, "stream": True}, headers=VOICE) as sse,
|
||||
):
|
||||
assert sse.status_code == 200
|
||||
assert sse.headers["content-type"].startswith("text/event-stream")
|
||||
buffer = "".join([chunk async for chunk in sse.aiter_text()])
|
||||
for block in buffer.split("\n\n"):
|
||||
lines = [line for line in block.splitlines() if line]
|
||||
if len(lines) == 2 and lines[0].startswith("event: "):
|
||||
frames.append((lines[0][7:], json.loads(lines[1][6:])))
|
||||
assert [name for name, _ in frames] == ["delta", "delta", "done"]
|
||||
assert [f["text"] for _, f in frames[:2]] == ["живу, ", "слушаю"]
|
||||
assert frames[-1][1]["reply"] == "живу, слушаю"
|
||||
assert frames[-1][1]["request_id"] == ASK["request_id"]
|
||||
Reference in New Issue
Block a user