5809ad3adb
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>
261 lines
10 KiB
Python
261 lines
10 KiB
Python
"""Real SQLite tests: ownership, joined pagination, exports and malformed requests."""
|
|
|
|
import base64
|
|
import importlib.util
|
|
import json
|
|
from pathlib import Path
|
|
import zlib
|
|
|
|
import pytest
|
|
from quart import Quart
|
|
from quart_jwt_extended import JWTManager, create_access_token
|
|
from tortoise import Tortoise
|
|
|
|
from blueprints.conversation.history import history_blueprint, list_owned_conversations
|
|
from blueprints.conversation.models import (
|
|
Conversation,
|
|
ConversationMessage,
|
|
Speaker,
|
|
UserMemory,
|
|
)
|
|
from blueprints.users.models import User
|
|
|
|
|
|
@pytest.fixture
|
|
async def history_app():
|
|
await Tortoise.init(
|
|
db_url="sqlite://:memory:",
|
|
modules={
|
|
"models": ["blueprints.users.models", "blueprints.conversation.models"]
|
|
},
|
|
)
|
|
await Tortoise.generate_schemas()
|
|
app = Quart(__name__)
|
|
app.config.update(
|
|
TESTING=True,
|
|
JWT_SECRET_KEY="history-test-secret-at-least-thirty-two-characters",
|
|
)
|
|
JWTManager(app)
|
|
app.register_blueprint(history_blueprint)
|
|
owner = await User.create(username="owner", email="owner@example.com")
|
|
foreign = await User.create(username="foreign", email="foreign@example.com")
|
|
async with app.app_context():
|
|
headers = {
|
|
"Authorization": f"Bearer {create_access_token(identity=str(owner.id))}"
|
|
}
|
|
try:
|
|
yield app.test_client(), headers, owner, foreign
|
|
finally:
|
|
await Tortoise.close_connections()
|
|
|
|
|
|
async def message(conversation, text, speaker=Speaker.USER, **kwargs):
|
|
return await ConversationMessage.create(
|
|
conversation=conversation, text=text, speaker=speaker, **kwargs
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_search_unique_pagination_and_owner_isolation(history_app):
|
|
client, headers, owner, foreign = history_app
|
|
a = await Conversation.create(user=owner, name="ordinary")
|
|
b = await Conversation.create(user=owner, name="needle title", pinned=True)
|
|
secret = await Conversation.create(user=foreign, name="needle foreign", pinned=True)
|
|
await Conversation.create(name="needle unowned")
|
|
for _ in range(5):
|
|
await message(a, "many needle message matches")
|
|
await message(secret, "needle secret")
|
|
first = await list_owned_conversations(owner.id, "needle", 1, 0)
|
|
second = await list_owned_conversations(owner.id, "needle", 1, 1)
|
|
assert [first[0]["id"], second[0]["id"]] == [str(b.id), str(a.id)]
|
|
assert await list_owned_conversations(owner.id, "needle", 1, 2) == []
|
|
assert "needle" in second[0]["snippet"]
|
|
assert second[0]["match_message_id"]
|
|
response = await client.get(
|
|
"/api/history/conversations?search=NEEDLE&limit=10", headers=headers
|
|
)
|
|
assert {row["id"] for row in await response.get_json()} == {str(a.id), str(b.id)}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pin_owner_only_boolean_and_recency(history_app):
|
|
client, headers, owner, foreign = history_app
|
|
own = await Conversation.create(user=owner, name="own")
|
|
other = await Conversation.create(user=foreign, name="other")
|
|
for identifier in (other.id, "malformed"):
|
|
response = await client.patch(
|
|
f"/api/history/conversations/{identifier}/pin",
|
|
headers=headers,
|
|
json={"pinned": True},
|
|
)
|
|
assert response.status_code == 404
|
|
for body in ({}, {"pinned": "false"}, {"pinned": 1}, [], None):
|
|
response = await client.patch(
|
|
f"/api/history/conversations/{own.id}/pin", headers=headers, json=body
|
|
)
|
|
assert response.status_code == 400
|
|
original = own.updated_at
|
|
for pinned in (True, False):
|
|
response = await client.patch(
|
|
f"/api/history/conversations/{own.id}/pin",
|
|
headers=headers,
|
|
json={"pinned": pinned},
|
|
)
|
|
assert response.status_code == 200
|
|
await own.refresh_from_db()
|
|
assert own.pinned is pinned
|
|
assert own.updated_at == original
|
|
await other.refresh_from_db()
|
|
assert not other.pinned
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_display_and_attachment_provenance(history_app):
|
|
client, headers, owner, foreign = history_app
|
|
own = await Conversation.create(user=owner, name='quoted "title"')
|
|
other = await Conversation.create(user=foreign, name="FOREIGN SECRET")
|
|
await message(other, "FOREIGN CONTENT")
|
|
await message(own, "hello\n\n📎 notes.md", image_key="foreign/private/secret.png")
|
|
await message(own, "[web_search result]\nHIDDEN RAW TOOL CONTEXT", Speaker.TOOL)
|
|
await message(own, "answer", Speaker.SIMBA)
|
|
await message(own, "malformed tool payload\nHIDDEN MORE", Speaker.TOOL)
|
|
response = await client.get(
|
|
f"/api/history/conversations/{own.id}/export", headers=headers
|
|
)
|
|
assert response.status_code == 200
|
|
data = await response.get_json()
|
|
text = json.dumps(data)
|
|
for secret in ("HIDDEN RAW", "HIDDEN MORE", "FOREIGN", "foreign/private"):
|
|
assert secret not in text
|
|
assert data["messages"][1]["text"] == "[web_search result]"
|
|
assert data["messages"][1]["provenance"] == "displayed_tool_header"
|
|
assert data["messages"][0]["attachments"] == [
|
|
{"kind": "file", "recorded_name": "notes.md", "contents_included": False},
|
|
{"kind": "image", "stored_reference_present": True, "contents_included": False},
|
|
]
|
|
assert "attachment; filename=" in response.headers["Content-Disposition"]
|
|
assert response.headers["Cache-Control"] == "no-store"
|
|
md = await client.get(
|
|
f"/api/history/conversations/{own.id}/export?format=markdown", headers=headers
|
|
)
|
|
markdown = await md.get_data(as_text=True)
|
|
assert md.status_code == 200 and "text/markdown" in md.content_type
|
|
assert "📎 notes.md" in markdown and "Raw tool context" in markdown
|
|
assert "HIDDEN" not in markdown
|
|
for identifier in (other.id, "bad-uuid"):
|
|
assert (
|
|
await client.get(
|
|
f"/api/history/conversations/{identifier}/export", headers=headers
|
|
)
|
|
).status_code == 404
|
|
assert (
|
|
await client.get(
|
|
f"/api/history/conversations/{own.id}/export?format=html", headers=headers
|
|
)
|
|
).status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_memory_owner_only_correction_forget_and_validation(history_app):
|
|
client, headers, owner, foreign = history_app
|
|
own = await UserMemory.create(user=owner, content="old fact")
|
|
other = await UserMemory.create(user=foreign, content="FOREIGN MEMORY")
|
|
rows = await (await client.get("/api/history/memories", headers=headers)).get_json()
|
|
assert [row["id"] for row in rows] == [str(own.id)]
|
|
for identifier in (other.id, "invalid"):
|
|
for method in (client.patch, client.delete):
|
|
assert (
|
|
await method(
|
|
f"/api/history/memories/{identifier}",
|
|
headers=headers,
|
|
json={"content": "stolen"},
|
|
)
|
|
).status_code == 404
|
|
for body in (
|
|
{},
|
|
[],
|
|
{"content": None},
|
|
{"content": 3},
|
|
{"content": " "},
|
|
{"content": "x" * 10001},
|
|
):
|
|
assert (
|
|
await client.patch(
|
|
f"/api/history/memories/{own.id}", headers=headers, json=body
|
|
)
|
|
).status_code == 400
|
|
response = await client.patch(
|
|
f"/api/history/memories/{own.id}",
|
|
headers=headers,
|
|
json={"content": " corrected fact "},
|
|
)
|
|
assert response.status_code == 200
|
|
assert (await response.get_json())["content"] == "corrected fact"
|
|
await own.refresh_from_db()
|
|
assert own.content == "corrected fact"
|
|
assert (
|
|
await client.delete(f"/api/history/memories/{own.id}", headers=headers)
|
|
).status_code == 204
|
|
assert await UserMemory.get_or_none(id=own.id) is None
|
|
await other.refresh_from_db()
|
|
assert other.content == "FOREIGN MEMORY"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_memory_pagination(history_app):
|
|
client, headers, owner, _ = history_app
|
|
for i in range(5):
|
|
await UserMemory.create(user=owner, content=f"fact {i}")
|
|
first = await (
|
|
await client.get("/api/history/memories?limit=2&offset=0", headers=headers)
|
|
).get_json()
|
|
second = await (
|
|
await client.get("/api/history/memories?limit=2&offset=2", headers=headers)
|
|
).get_json()
|
|
assert len(first) == len(second) == 2
|
|
assert {r["id"] for r in first}.isdisjoint(r["id"] for r in second)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_authentication_and_malformed_pagination(history_app):
|
|
client, headers, owner, _ = history_app
|
|
own = await Conversation.create(user=owner, name="own")
|
|
memory = await UserMemory.create(user=owner, content="fact")
|
|
for method, path in (
|
|
(client.get, "/conversations"),
|
|
(client.get, "/memories"),
|
|
(client.get, f"/conversations/{own.id}/export"),
|
|
(client.patch, f"/conversations/{own.id}/pin"),
|
|
(client.patch, f"/memories/{memory.id}"),
|
|
(client.delete, f"/memories/{memory.id}"),
|
|
):
|
|
assert (await method("/api/history" + path)).status_code == 401
|
|
for path in ("conversations", "memories"):
|
|
for query in ("limit=0", "limit=101", "offset=-1", "limit=oops", "offset=1.5"):
|
|
assert (
|
|
await client.get(f"/api/history/{path}?{query}", headers=headers)
|
|
).status_code == 400
|
|
assert (
|
|
await client.get(
|
|
"/api/history/conversations?search=" + "x" * 501, headers=headers
|
|
)
|
|
).status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_migration_adds_pinned_and_records_state(history_app):
|
|
migration_path = (
|
|
Path(__file__).parents[2]
|
|
/ "migrations/models/20_20261012231017_application_foundation.py"
|
|
)
|
|
spec = importlib.util.spec_from_file_location("history_migration", migration_path)
|
|
migration = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(migration)
|
|
sql = await migration.upgrade(None)
|
|
assert 'ALTER TABLE "conversations" ADD "pinned" BOOL NOT NULL DEFAULT False' in sql
|
|
state = json.loads(zlib.decompress(base64.b64decode(migration.MODELS_STATE)))
|
|
assert any(
|
|
field["name"] == "pinned" and field["default"] is False
|
|
for field in state["models.Conversation"]["data_fields"]
|
|
)
|