feat: live agent answers (SSE streaming) + «Чат» tab on phones
CI / ruff · pytest (pull_request) Successful in 2m43s
CI / ruff · pytest (pull_request) Successful in 2m43s
- POST /dialog/{token}/events/stream: Server-Sent Events with the agent's steps («Смотрю вашу карту ЕМИАС…»,
«Ищу в клинических рекомендациях…»), the answer as the model writes it (OpenRouter stream=true), then the
final replies (same as /events, incl. the forbidden-output filter and sources). X-Accel-Buffering: no.
- Web: free-text messages use the stream — a live bubble with steps, typing dots and a cursor; falls back to
the plain request if the stream breaks.
- Phones: the bottom tab bar gets «Чат» (it had no way back to the chat).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
"""Live agent output: OpenRouter stream parsing and the SSE endpoint (status → delta → done)."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import session
|
||||
from app.db.bootstrap import init_db
|
||||
from app.llm import agent
|
||||
from app.llm.agent import stream
|
||||
from app.llm.agent.providers import OpenRouterProvider
|
||||
from app.main import app
|
||||
|
||||
MEM = "sqlite+aiosqlite:///:memory:"
|
||||
|
||||
|
||||
def _sse(*chunks: dict) -> bytes:
|
||||
lines = [": OPENROUTER PROCESSING", ""]
|
||||
lines += [f"data: {json.dumps(c, ensure_ascii=False)}" for c in chunks] + ["data: [DONE]", ""]
|
||||
return "\n\n".join(lines).encode()
|
||||
|
||||
|
||||
async def test_openrouter_stream_deltas_and_tool_calls():
|
||||
body = _sse(
|
||||
{"choices": [{"delta": {"content": "Сейчас "}}]},
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"delta": {
|
||||
"tool_calls": [{"index": 0, "id": "c1", "function": {"name": "get_patient_", "arguments": ""}}]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": {"name": "record", "arguments": "{}"}}]}}]},
|
||||
)
|
||||
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, content=body)))
|
||||
provider = OpenRouterProvider(Settings(_env_file=None, openrouter_api_key="k"), client=client)
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
token = stream.STREAM.set(q)
|
||||
try:
|
||||
turn = await provider.chat([{"role": "user", "content": "x"}], [])
|
||||
finally:
|
||||
stream.STREAM.reset(token)
|
||||
assert [c.name for c in turn.tool_calls] == ["get_patient_record"]
|
||||
events = [q.get_nowait() for _ in range(q.qsize())]
|
||||
assert ("delta", "Сейчас ") in events and ("reset", None) in events
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
await session.configure(identity_url=MEM, clinical_url=MEM)
|
||||
await init_db()
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://t") as c:
|
||||
yield c
|
||||
await session.configure()
|
||||
|
||||
|
||||
async def test_sse_endpoint_streams_steps_then_final(client, monkeypatch):
|
||||
async def fake(token, text, **kw):
|
||||
stream.emit("status", stream.TOOL_STATUS["get_patient_record"])
|
||||
stream.emit("delta", "Ответ ")
|
||||
stream.emit("delta", "агента")
|
||||
return agent.AgentAnswer(text="Ответ агента", sources=[], tools=["get_patient_record"], provider="fake")
|
||||
|
||||
monkeypatch.setattr(agent, "answer_chat", fake)
|
||||
token = (await client.post("/api/v1/auth/demo/fresh", json={})).json()["token"]
|
||||
kinds = []
|
||||
async with client.stream(
|
||||
"POST", f"/api/v1/dialog/{token}/events/stream", json={"type": "text", "text": "Можно ибупрофен?"}
|
||||
) as resp:
|
||||
assert resp.headers["content-type"].startswith("text/event-stream")
|
||||
async for line in resp.aiter_lines():
|
||||
if line.startswith("event:"):
|
||||
kinds.append(line[6:].strip())
|
||||
if line.startswith("data:") and kinds and kinds[-1] == "done":
|
||||
final = json.loads(line[5:])
|
||||
assert kinds[0] == "status" and "delta" in kinds and kinds[-1] == "done"
|
||||
assert any("Ответ агента" in r["text"] for r in final)
|
||||
Reference in New Issue
Block a user