Files
simbarag/tests/unit/test_artifacts.py
T
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

391 lines
14 KiB
Python

"""Tests for artifact creation (blueprints/artifacts/logic.py) and the owner-scoped API."""
from datetime import timedelta
from types import SimpleNamespace
import pytest
from quart import Quart
from quart_jwt_extended import JWTManager, create_access_token
from tortoise import Tortoise
from blueprints.artifacts import artifacts_blueprint
from blueprints.artifacts.logic import ARTIFACT_MAX_BYTES, create_artifact_from_output
from blueprints.artifacts.models import Artifact
from blueprints.conversation.models import Conversation, ConversationMessage
from blueprints.users.models import User
@pytest.fixture
async def db():
await Tortoise.init(
db_url="sqlite://:memory:",
modules={
"models": [
"blueprints.users.models",
"blueprints.conversation.models",
"blueprints.conversation.run_models",
"blueprints.artifacts.models",
]
},
)
await Tortoise.generate_schemas()
yield
await Tortoise.close_connections()
@pytest.fixture
async def owner(db):
user = await User.create(username="owner", email="owner@example.com")
conversation = await Conversation.create(name="c", user=user)
message = await ConversationMessage.create(
text="[mealie_get_recipe result]\nSoup",
conversation=conversation,
speaker="tool",
)
return user, conversation, message
def _output(artifact):
return SimpleNamespace(content="text", artifact=artifact)
RECIPE = {"kind": "mealie.recipe", "title": "Soup", "data": {"slug": "soup"}}
class TestCreateArtifact:
async def test_creates_and_links(self, owner):
user, conversation, message = owner
artifact = await create_artifact_from_output(
output=_output({**RECIPE, "source_ref": {"system": "mealie"}}),
tool="mealie_get_recipe",
user=user,
conversation=conversation,
message=message,
)
assert artifact is not None
saved = await Artifact.get(id=artifact.id)
assert saved.message_id == message.id
assert saved.kind == "mealie.recipe"
assert saved.data == {"slug": "soup"}
assert saved.source_ref == {"system": "mealie"}
assert saved.version == 1
@pytest.mark.parametrize(
"artifact", [None, [{"page_content": "doc"}], {"title": "no kind"}, "str"]
)
async def test_ignores_non_artifacts(self, owner, artifact):
user, conversation, message = owner
result = await create_artifact_from_output(
output=_output(artifact),
tool="simba_search",
user=user,
conversation=conversation,
message=message,
)
assert result is None
assert await Artifact.all().count() == 0
async def test_plain_string_output(self, owner):
user, conversation, message = owner
result = await create_artifact_from_output(
output="just text", tool="t", user=user, conversation=conversation
)
assert result is None
async def test_drops_oversized(self, owner):
user, conversation, message = owner
big = {**RECIPE, "data": {"blob": "x" * (ARTIFACT_MAX_BYTES + 1)}}
result = await create_artifact_from_output(
output=_output(big), tool="t", user=user, conversation=conversation
)
assert result is None
assert await Artifact.all().count() == 0
async def test_drops_unserialisable(self, owner):
user, conversation, message = owner
bad = {**RECIPE, "data": {"obj": object()}}
result = await create_artifact_from_output(
output=_output(bad), tool="t", user=user, conversation=conversation
)
assert result is None
async def test_message_delete_sets_null(self, owner):
user, conversation, message = owner
artifact = await create_artifact_from_output(
output=_output(RECIPE),
tool="t",
user=user,
conversation=conversation,
message=message,
)
await message.delete()
assert (await Artifact.get(id=artifact.id)).message_id is None
async def test_conversation_delete_cascades(self, owner):
user, conversation, message = owner
await create_artifact_from_output(
output=_output(RECIPE), tool="t", user=user, conversation=conversation
)
await conversation.delete()
assert await Artifact.all().count() == 0
@pytest.fixture
def app():
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1)
JWTManager(app)
app.register_blueprint(artifacts_blueprint)
return app
async def _headers(app, user):
async with app.app_context():
token = create_access_token(identity=str(user.id))
return {"Authorization": f"Bearer {token}"}
class TestArtifactRoutes:
async def _setup(self, owner):
user, conversation, message = owner
artifact = await create_artifact_from_output(
output=_output(RECIPE),
tool="mealie_get_recipe",
user=user,
conversation=conversation,
message=message,
)
other = await User.create(username="other", email="other@example.com")
return user, other, conversation, message, artifact
async def test_list_for_owner(self, app, owner):
user, _, conversation, message, artifact = await self._setup(owner)
resp = await app.test_client().get(
f"/api/artifacts?conversation_id={conversation.id}",
headers=await _headers(app, user),
)
assert resp.status_code == 200
body = await resp.get_json()
assert len(body) == 1
assert body[0]["id"] == str(artifact.id)
assert body[0]["kind"] == "mealie.recipe"
assert body[0]["title"] == "Soup"
assert body[0]["tool"] == "mealie_get_recipe"
assert body[0]["message_id"] == str(message.id)
assert "created_at" in body[0]
assert "data" not in body[0]
async def test_get_for_owner(self, app, owner):
user, _, conversation, message, artifact = await self._setup(owner)
resp = await app.test_client().get(
f"/api/artifacts/{artifact.id}", headers=await _headers(app, user)
)
assert resp.status_code == 200
body = await resp.get_json()
assert body["data"] == {"slug": "soup"}
assert body["source_ref"] is None
assert body["conversation_id"] == str(conversation.id)
async def test_other_user_gets_404(self, app, owner):
_, other, conversation, _, artifact = await self._setup(owner)
headers = await _headers(app, other)
client = app.test_client()
resp = await client.get(f"/api/artifacts/{artifact.id}", headers=headers)
assert resp.status_code == 404
resp = await client.get(
f"/api/artifacts?conversation_id={conversation.id}", headers=headers
)
assert resp.status_code == 404
async def test_invalid_id_is_404(self, app, owner):
user, *_ = await self._setup(owner)
resp = await app.test_client().get(
"/api/artifacts/not-a-uuid", headers=await _headers(app, user)
)
assert resp.status_code == 404
async def test_invalid_id_is_404_without_querying(self, app, owner, monkeypatch):
user, *_ = await self._setup(owner)
async def _boom(*args, **kwargs):
raise AssertionError("queried with an invalid id")
monkeypatch.setattr(Artifact, "get_or_none", _boom)
resp = await app.test_client().get(
"/api/artifacts/not-a-uuid", headers=await _headers(app, user)
)
assert resp.status_code == 404
async def test_invalid_conversation_id_is_400_without_querying(
self, app, owner, monkeypatch
):
user, *_ = await self._setup(owner)
async def _boom(*args, **kwargs):
raise AssertionError("queried with an invalid id")
monkeypatch.setattr(Conversation, "get_or_none", _boom)
resp = await app.test_client().get(
"/api/artifacts?conversation_id=garbage", headers=await _headers(app, user)
)
assert resp.status_code == 400
async def test_requires_auth(self, app, owner):
await self._setup(owner)
resp = await app.test_client().get("/api/artifacts/whatever")
assert resp.status_code == 401
async def test_missing_conversation_id_is_400(self, app, owner):
user, *_ = await self._setup(owner)
resp = await app.test_client().get(
"/api/artifacts", headers=await _headers(app, user)
)
assert resp.status_code == 400
class TestConversationPayload:
async def test_tool_messages_gain_tool_label_artifact(self, owner):
from blueprints.conversation import conversation_blueprint
user, conversation, message = owner
await ConversationMessage.create(
text="[web_search result]\nresults",
conversation=conversation,
speaker="tool",
)
await ConversationMessage.create(
text="hello", conversation=conversation, speaker="user"
)
artifact = await create_artifact_from_output(
output=_output(RECIPE),
tool="mealie_get_recipe",
user=user,
conversation=conversation,
message=message,
)
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
JWTManager(app)
app.register_blueprint(conversation_blueprint)
resp = await app.test_client().get(
f"/api/conversation/{conversation.id}", headers=await _headers(app, user)
)
assert resp.status_code == 200
by_text = {m["text"]: m for m in (await resp.get_json())["messages"]}
recipe = by_text["[mealie_get_recipe result]"]
assert recipe["tool"] == "mealie_get_recipe"
assert recipe["label"] == "Fetching recipe…"
assert recipe["artifact"] == {
"id": str(artifact.id),
"kind": "mealie.recipe",
"title": "Soup",
}
search = by_text["[web_search result]"]
assert search["tool"] == "web_search"
assert search["artifact"] is None
assert "tool" not in by_text["hello"]
class _FakeAgent:
def __init__(self, events):
self._events = events
async def astream_events(self, payload, version, config):
for event in self._events:
yield event
class TestStreamQuery:
async def test_emits_v2_tool_events_and_saves_artifact(self, owner, monkeypatch):
import json
import blueprints.conversation as conv
from langchain_core.messages import AIMessage, ToolMessage
user, conversation, _ = owner
events = [
{
"event": "on_tool_start",
"name": "mealie_get_recipe",
"run_id": "r1",
"data": {"input": {"recipe_slug": "soup", "config": {}}},
},
{
"event": "on_tool_start",
"name": "web_search",
"run_id": "r2",
"data": {"input": {"query": "cats"}},
},
{
"event": "on_tool_end",
"name": "mealie_get_recipe",
"run_id": "r1",
"data": {
"output": ToolMessage(
content="Soup recipe",
artifact=RECIPE,
tool_call_id="c1",
name="mealie_get_recipe",
)
},
},
{
"event": "on_tool_error",
"name": "web_search",
"run_id": "r2",
"data": {"error": RuntimeError("boom")},
},
{
"event": "on_chain_end",
"name": "agent",
"data": {"output": {"messages": [AIMessage(content="Done")]}},
},
]
monkeypatch.setattr(conv.runtime, "get_agent_for", lambda u: _FakeAgent(events))
async def _prompt(user_id, query=""):
return "system"
monkeypatch.setattr(conv.runtime, "build_system_prompt_with_memories", _prompt)
app = Quart(__name__)
app.config["JWT_SECRET_KEY"] = "test-secret"
JWTManager(app)
app.register_blueprint(conv.conversation_blueprint)
resp = await app.test_client().post(
"/api/conversation/stream-query",
json={"query": "soup?", "conversation_id": str(conversation.id)},
headers=await _headers(app, user),
)
body = (await resp.get_data()).decode()
frames = [
json.loads(line[len("data: ") :])
for line in body.splitlines()
if line.startswith("data: {")
]
by_type = {}
for f in frames:
by_type.setdefault(f["type"], []).append(f)
starts = by_type["tool_start"]
assert starts[0]["input"] == {"recipe_slug": "soup"}
assert starts[0]["label"] == "Fetching recipe…"
ends = {f["run_id"]: f for f in by_type["tool_end"]}
artifact = await Artifact.get(tool="mealie_get_recipe")
assert ends["r1"]["status"] == "ok"
assert ends["r1"]["artifact"] == {
"id": str(artifact.id),
"kind": "mealie.recipe",
"title": "Soup",
}
assert ends["r2"]["status"] == "error"
assert ends["r2"]["artifact"] is None
assert by_type["response"][0]["message"] == "Done"
tool_message = await ConversationMessage.get(id=artifact.message_id)
assert tool_message.text.startswith("[mealie_get_recipe result]")