Files
simbarag/tests/unit/test_agent_runtime.py
ryan 5809ad3adb
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s
Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime
- One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for
  web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions,
  memories, summaries and metrics match across channels.
- Serialize turns per checkpoint thread; /query validates input and returns 409
  while a durable run owns the conversation.

Reconnectable chat (durable runs)
- POST /runs creates an idempotent, owner-scoped run; GET .../events replays an
  ordered SSE log by cursor, so generation survives refresh and device switches.
  Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter.
- Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of
  polling; keep buffered text when a run is stopped.
- Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering
  the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded.

Scheduled messages and cron
- Atomic delivery claims, attempt history and explicit retries; ambiguous sends
  become "uncertain" and are never resent automatically.
- Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner.
- Cancelling never rewrites a started delivery; series stop via tombstones.
  Legacy sent rows are not forked into new series. Malformed bodies return 400.

History, integrations and retrieval
- Message-content search, pinned conversations, JSON/Markdown export and a
  memory editor.
- Integration status panel and YNAB data-freshness provenance.
- Date-filtered document/note search with stable citations and a source viewer.

Migration 20 is generated by aerich.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 23:11:04 -04:00

138 lines
5.0 KiB
Python

import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langchain_core.messages import AIMessage
from quart import Quart
from blueprints.conversation import runtime
from metrics import AGENT_REQUESTS_TOTAL
@pytest.mark.parametrize(
"channel", ["web", "email", "imessage", "whatsapp", "scheduled", "cron"]
)
@pytest.mark.parametrize("admin", [False, True])
async def test_channels_share_identity_memories_permissions_and_summary(
monkeypatch, channel, admin
):
app = Quart(__name__)
user = SimpleNamespace(id="owner", is_admin=lambda: admin)
app.main_agent = SimpleNamespace(
ainvoke=AsyncMock(return_value={"messages": [AIMessage(content="answer")]}),
aget_state=AsyncMock(return_value=SimpleNamespace(values={"messages": []})),
)
app.admin_agent = SimpleNamespace(
ainvoke=AsyncMock(
return_value={"messages": [AIMessage(content="admin answer")]}
),
aget_state=AsyncMock(return_value=SimpleNamespace(values={"messages": []})),
)
summary = AIMessage(content="Saved constraints")
app.conversation_summary_store = SimpleNamespace(
update=AsyncMock(return_value=SimpleNamespace(as_message=lambda: summary))
)
prompt = AsyncMock(return_value="Remember the budget")
monkeypatch.setattr(runtime, "build_system_prompt_with_memories", prompt)
before = AGENT_REQUESTS_TOTAL.labels(channel=channel, result="success")._value.get()
async with app.app_context():
response = await runtime.invoke_agent_for_user(
user, "What is my budget?", "thread", channel=channel
)
agent = app.admin_agent if admin else app.main_agent
assert response["messages"][-1].content == ("admin answer" if admin else "answer")
config = agent.ainvoke.call_args.kwargs["config"]["configurable"]
assert config["user_id"] == "owner"
assert config["is_admin"] is admin
assert config["thread_id"] == "thread"
assert config["channel"] == channel
assert config["system_prompt"] == "Remember the budget"
assert config["conversation_summary"] is summary
assert config["request_id"]
prompt.assert_awaited_once_with("owner", query="What is my budget?")
assert (
AGENT_REQUESTS_TOTAL.labels(channel=channel, result="success")._value.get()
== before + 1
)
async def test_anonymous_invocation_is_rejected():
with pytest.raises(ValueError, match="authenticated user"):
await runtime.invoke_agent_for_user(None, "hello", "thread")
@pytest.mark.parametrize(
"error, outcome",
[(RuntimeError("failed"), "error"), (asyncio.CancelledError(), "cancelled")],
)
async def test_shared_invocation_measures_failed_and_cancelled_generation(
error, outcome
):
agent = SimpleNamespace(ainvoke=AsyncMock(side_effect=error))
turn = runtime.PreparedTurn(
agent,
{"messages": []},
{
"configurable": {
"channel": "web",
"request_id": "test",
"thread_id": "t",
"user_id": "u",
}
},
)
before = AGENT_REQUESTS_TOTAL.labels(channel="web", result=outcome)._value.get()
with pytest.raises(type(error)):
await runtime.invoke_prepared(turn)
assert (
AGENT_REQUESTS_TOTAL.labels(channel="web", result=outcome)._value.get()
== before + 1
)
async def test_concurrent_turns_on_one_thread_keep_every_exchange(
monkeypatch, tmp_path
):
import aiosqlite
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
from langgraph.graph import END, START, MessagesState, StateGraph
monkeypatch.setattr(
runtime, "build_system_prompt_with_memories", AsyncMock(return_value="sys")
)
async with aiosqlite.connect(tmp_path / "checkpoints.db") as conn:
saver = AsyncSqliteSaver(conn)
await saver.setup()
async def answer(state):
# Yield so an unserialized second turn would read stale history.
await asyncio.sleep(0.05)
return {
"messages": [
AIMessage(content="reply " + state["messages"][-1].content)
]
}
graph = StateGraph(MessagesState)
graph.add_node("answer", answer)
graph.add_edge(START, "answer")
graph.add_edge("answer", END)
agent = graph.compile(checkpointer=saver)
app = Quart(__name__)
app.main_agent = app.admin_agent = agent
user = SimpleNamespace(id="owner", is_admin=lambda: False)
async with app.app_context():
await asyncio.gather(
*[
runtime.invoke_agent_for_user(user, q, "same-thread")
for q in ("first", "second")
]
)
state = await agent.aget_state(
{"configurable": {"thread_id": "same-thread"}}
)
contents = [m.content for m in state.values["messages"]]
assert sorted(contents) == ["first", "reply first", "reply second", "second"]