feat(backends,jobs,telegram,conversations): mcp calls carry the conversation origin, quiet branches close asleep, fresh topics get named
This commit is contained in:
1 parent
7d812c7962
commit
944b68e275
9 files changed
+276
-15
No files matched your search
@@ -250,7 +250,11 @@ async def test_stream_envelope_and_index_rebase(cwd: Path) -> None:
|
||||
assert opts.strict_mcp_config is True
|
||||
assert opts.permission_mode == "bypassPermissions"
|
||||
assert opts.mcp_servers == {
|
||||
"firefly": {"type": "http", "url": "http://127.0.0.1:1/mcp/firefly/"}
|
||||
"firefly": {
|
||||
"type": "http",
|
||||
"url": "http://127.0.0.1:1/mcp/firefly/",
|
||||
"headers": {"X-Conversation-Origin": "conv-1"},
|
||||
}
|
||||
}
|
||||
assert opts.session_store is not None
|
||||
assert opts.resume is None
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
|
||||
from test_conversations import StubFrontend, World, world
|
||||
|
||||
from beaver_gateway.jobs.job import Job, JobRun
|
||||
from beaver_gateway.storage.models import Conversation
|
||||
|
||||
__all__ = ["world"]
|
||||
|
||||
|
||||
async def _noop(_: JobRun) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _run(world: World) -> JobRun:
|
||||
return JobRun(
|
||||
job=Job("test", _noop),
|
||||
trigger="test",
|
||||
payload={},
|
||||
scheduler=cast("Any", SimpleNamespace(conversations=world.conversations)),
|
||||
)
|
||||
|
||||
|
||||
async def _quiet_since(world: World, conv: Conversation, ago: timedelta) -> None:
|
||||
async def apply(row: Conversation) -> None:
|
||||
row.last_activity_at = datetime.now(UTC) - ago
|
||||
|
||||
await world.conversations._update(conv, apply) # noqa: SLF001
|
||||
|
||||
|
||||
async def test_close_quiet_closes_and_marks_only_idle_branches(world: World) -> None:
|
||||
marked: list[tuple[str, str]] = []
|
||||
|
||||
class MarkingFrontend(StubFrontend):
|
||||
async def mark_closed(self, conv: Conversation) -> bool:
|
||||
marked.append((conv.external_id, conv.status))
|
||||
return True
|
||||
|
||||
tg = MarkingFrontend("tg", ("master", "branch"))
|
||||
tg.conversations = world.conversations
|
||||
world.conversations._frontends = [tg] # noqa: SLF001
|
||||
convs = world.conversations
|
||||
master = await convs.create(kind="master", agent="a", session_id="m")
|
||||
idle = await convs.create(kind="branch", agent="a", parent=master, session_id="1")
|
||||
fresh = await convs.create(kind="branch", agent="a", parent=master, session_id="2")
|
||||
waiting = await convs.create(
|
||||
kind="branch", agent="a", parent=master, session_id="3"
|
||||
)
|
||||
await _quiet_since(world, idle, timedelta(days=4))
|
||||
await _quiet_since(world, fresh, timedelta(days=1))
|
||||
await _quiet_since(world, waiting, timedelta(days=4))
|
||||
await convs.queue.push(
|
||||
conversation_id=cast("int", waiting.id),
|
||||
priority="normal",
|
||||
origin="cron",
|
||||
text="later",
|
||||
)
|
||||
|
||||
closed = await _run(world).close_quiet(kind="branch", days=3)
|
||||
|
||||
assert [c.external_id for c in closed] == [idle.external_id]
|
||||
assert marked == [(idle.external_id, "closed")]
|
||||
assert (await convs.get(fresh.external_id)).status == "open"
|
||||
assert (await convs.get(waiting.external_id)).status == "open"
|
||||
|
||||
|
||||
async def test_inject_into_falls_back_to_the_master(world: World) -> None:
|
||||
convs = world.conversations
|
||||
master = await convs.create(kind="master", agent="a", session_id="m")
|
||||
branch = await convs.create(kind="branch", agent="a", parent=master)
|
||||
gone = await convs.create(kind="branch", agent="a", parent=master)
|
||||
await convs.set_status(gone, "closed")
|
||||
run = _run(world)
|
||||
|
||||
assert await run.inject_into(branch.external_id, "to branch", urgency="normal")
|
||||
assert await run.inject_into(gone.external_id, "to closed", urgency="normal")
|
||||
assert await run.inject_into(None, "to nobody", urgency="normal")
|
||||
|
||||
assert [i.text for i in await convs.queue.pending(branch.id)] == ["to branch"]
|
||||
assert [i.text for i in await convs.queue.pending(master.id)] == [
|
||||
"to closed",
|
||||
"to nobody",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import socket
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import StreamableHttpTransport
|
||||
from fastmcp.server.dependencies import get_http_headers
|
||||
|
||||
from beaver_gateway.backends.claude_sdk import ORIGIN_HEADER
|
||||
from beaver_gateway.mcp.internal_app import build_internal_app
|
||||
from beaver_gateway.mcp.types import McpServer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _serving(app: Any, port: int) -> AsyncIterator[None]:
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
||||
)
|
||||
task = asyncio.create_task(server.serve())
|
||||
while not server.started:
|
||||
await asyncio.sleep(0.02)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
server.should_exit = True
|
||||
await task
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_the_origin_header_reaches_the_upstream_mcp() -> None:
|
||||
upstream = FastMCP("upstream")
|
||||
|
||||
@upstream.tool
|
||||
def caller() -> str:
|
||||
return get_http_headers().get(ORIGIN_HEADER.lower(), "")
|
||||
|
||||
up, gateway = _free_port(), _free_port()
|
||||
app, urls, _ = build_internal_app(
|
||||
[McpServer.http(name="t3", url=f"http://127.0.0.1:{up}/mcp")],
|
||||
host="127.0.0.1",
|
||||
port=gateway,
|
||||
)
|
||||
seen = []
|
||||
async with (
|
||||
_serving(upstream.http_app(transport="http", path="/mcp"), up),
|
||||
_serving(app, gateway),
|
||||
):
|
||||
for conversation in ("conv-a", "conv-b"):
|
||||
transport = StreamableHttpTransport(
|
||||
urls["t3"], headers={ORIGIN_HEADER: conversation}
|
||||
)
|
||||
async with Client(transport) as client:
|
||||
result = await client.call_tool("caller", {})
|
||||
seen.append(result.data)
|
||||
assert seen == ["conv-a", "conv-b"]
|
||||
+33
-2
@@ -519,10 +519,10 @@ async def test_commands_status_merge_and_new(stack: Stack) -> None:
|
||||
frontend="telegram", external_id=f"{USER}/11"
|
||||
)
|
||||
assert branch.status == "merged"
|
||||
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work"]
|
||||
assert stack.bot.topics == ["🦫 General", "edit:11:work", "edit:11:✅ work"]
|
||||
stack.bot.message("/new отчёт")
|
||||
await stack.until(lambda: stack.sent_with("in a new topic"), what="new topic")
|
||||
assert stack.bot.topics == ["🦫 General", "edit:11:✅ work", "отчёт"]
|
||||
assert stack.bot.topics[-1] == "отчёт"
|
||||
child = await stack.world.conversations.find_bound(
|
||||
frontend="telegram", external_id=f"{USER}/902"
|
||||
)
|
||||
@@ -1298,3 +1298,34 @@ def test_a_tool_call_between_progress_updates_starts_a_new_paragraph() -> None:
|
||||
)
|
||||
feed(_thinking_delta("now writing it back"))
|
||||
assert draft.text == "reading the note\n\nnow writing it back"
|
||||
|
||||
|
||||
async def test_a_closed_topic_is_marked_asleep_and_wakes_with_a_new_branch(
|
||||
stack: Stack,
|
||||
) -> None:
|
||||
stack.bot.message("кухня на неделю\nи ещё строка", thread=12)
|
||||
await stack.until(lambda: stack.sent_with("кухня на неделю"), what="reply")
|
||||
assert stack.bot.topics[-1] == "edit:12:кухня на неделю"
|
||||
branch = await stack.world.conversations.find_bound(
|
||||
frontend="telegram", external_id=f"{USER}/12"
|
||||
)
|
||||
closed = await stack.world.conversations.close(branch)
|
||||
assert await stack.world.conversations.mark_closed(closed)
|
||||
assert stack.bot.topics[-1] == "edit:12:💤 кухня на неделю"
|
||||
stack.tg._topic_names.clear() # noqa: SLF001
|
||||
stack.bot.message("а в четверг?", thread=12)
|
||||
await stack.until(lambda: stack.sent_with("а в четверг?"), what="new branch")
|
||||
assert stack.bot.topics[-1] == "edit:12:кухня на неделю"
|
||||
fresh = await stack.world.conversations.find_bound(
|
||||
frontend="telegram", external_id=f"{USER}/12"
|
||||
)
|
||||
assert fresh.id != branch.id
|
||||
assert fresh.status == "open"
|
||||
assert fresh.title == "кухня на неделю"
|
||||
|
||||
|
||||
async def test_a_named_topic_keeps_its_name(stack: Stack) -> None:
|
||||
stack.bot.topic_created("план", 13)
|
||||
stack.bot.message("hello", thread=13)
|
||||
await stack.until(lambda: stack.sent_with("hello"), what="reply")
|
||||
assert not any(t.startswith("edit:13:") for t in stack.bot.topics)
|
||||
Reference in new issue
Block a user