feat(pheby): accept inbound chat attachments
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
"""Inbound attachment tests: upload route, anchoring, chat.send integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from pheby import protocol as proto
|
||||
from pheby.attachments import AttachmentStore
|
||||
from pheby.server import PhebyServer
|
||||
|
||||
from test_pheby import FakeClientConnection, make_server
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_bridge_run_state():
|
||||
"""Reset hermes_bridge global run state between tests."""
|
||||
from pheby import hermes_bridge as hb
|
||||
yield
|
||||
hb._ACTIVE_RUNS.clear()
|
||||
hb._TOOL_EVENTS.clear()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Store: register_bytes / anchor / describe
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestRegisterBytes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_registers_and_describes_inbound(self, tmp_path):
|
||||
store = AttachmentStore(root=tmp_path / "att")
|
||||
desc = await store.register_bytes(
|
||||
b"# hello\nworld\n",
|
||||
conversation_id="c1",
|
||||
filename="notes.md",
|
||||
mime_type="text/markdown",
|
||||
)
|
||||
assert desc is not None
|
||||
assert desc["direction"] == "inbound"
|
||||
assert desc["filename"] == "notes.md"
|
||||
assert desc["kind"] == "document"
|
||||
assert desc["mime_type"] == "text/markdown"
|
||||
assert desc["size"] == len(b"# hello\nworld\n")
|
||||
assert desc["message_id"] is None
|
||||
# Blob is actually on disk with exact bytes.
|
||||
blob = store.get_blob_path(desc["attachment_id"])
|
||||
assert blob is not None and blob.read_bytes() == b"# hello\nworld\n"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_ext_becomes_image_kind(self, tmp_path):
|
||||
store = AttachmentStore(root=tmp_path / "att")
|
||||
desc = await store.register_bytes(
|
||||
b"\x89PNG\r\n", conversation_id="c1", filename="pic.png")
|
||||
assert desc["kind"] == "image"
|
||||
assert desc["inline_image"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bad_mime_falls_back(self, tmp_path):
|
||||
store = AttachmentStore(root=tmp_path / "att")
|
||||
desc = await store.register_bytes(
|
||||
b"x", conversation_id="c1", filename="f.bin",
|
||||
mime_type="not-a-mime")
|
||||
assert desc["mime_type"] == "application/octet-stream"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_android_mime_uses_markdown_extension(self, tmp_path):
|
||||
store = AttachmentStore(root=tmp_path / "att")
|
||||
desc = await store.register_bytes(
|
||||
b"# context\n", conversation_id="c1", filename="notes.md",
|
||||
mime_type="application/octet-stream")
|
||||
assert desc["mime_type"] == "text/markdown"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anchor_persists(self, tmp_path):
|
||||
root = tmp_path / "att"
|
||||
store = AttachmentStore(root=root)
|
||||
desc = await store.register_bytes(
|
||||
b"x", conversation_id="c1", filename="f.bin")
|
||||
store.anchor_message(desc["attachment_id"], "msg123")
|
||||
store2 = AttachmentStore(root=root)
|
||||
store2._loaded = False
|
||||
store2._load_index()
|
||||
d2 = store2.describe(desc["attachment_id"])
|
||||
assert d2["message_id"] == "msg123"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# HTTP upload route
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestUploadRoute:
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_requires_auth(self, tmp_path):
|
||||
aiohttp = pytest.importorskip("aiohttp")
|
||||
server = make_server(tmp_path)
|
||||
server.config.port = 0
|
||||
assert await server.start()
|
||||
try:
|
||||
port = server._site._server.sockets[0].getsockname()[1]
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"http://127.0.0.1:{port}/attachments",
|
||||
params={"conversation_id": "a" * 32},
|
||||
data=b"hello") as resp:
|
||||
assert resp.status == 401
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_roundtrip_without_premature_broadcast(self, tmp_path):
|
||||
aiohttp = pytest.importorskip("aiohttp")
|
||||
server = make_server(tmp_path)
|
||||
server.config.port = 0
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
server._clients["t"] = client
|
||||
cid = await server.router.new_conversation("Uploads")
|
||||
assert await server.start()
|
||||
try:
|
||||
port = server._site._server.sockets[0].getsockname()[1]
|
||||
headers = {"Authorization": "Bearer test-secret-abc123"}
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"http://127.0.0.1:{port}/attachments",
|
||||
params={"conversation_id": cid,
|
||||
"filename": "notes.md"},
|
||||
data=b"# title\nbody",
|
||||
headers={**headers,
|
||||
"Content-Type": "text/markdown"}) as resp:
|
||||
assert resp.status == 201
|
||||
body = await resp.json()
|
||||
desc = body["attachment"]
|
||||
assert desc["filename"] == "notes.md"
|
||||
assert desc["direction"] == "inbound"
|
||||
assert desc["conversation_id"] == cid
|
||||
# Unsent uploads stay private and out of conversation history.
|
||||
assert not [e for e in client.ws.events() if e["type"] == proto.S_ATTACHMENT_ADDED]
|
||||
assert server.store.list_for_conversation(cid) == []
|
||||
# download round-trips the bytes
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.get(
|
||||
f"http://127.0.0.1:{port}{desc['download_path']}",
|
||||
headers=headers) as resp:
|
||||
assert resp.status == 200
|
||||
assert await resp.read() == b"# title\nbody"
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_empty_body_rejected(self, tmp_path):
|
||||
aiohttp = pytest.importorskip("aiohttp")
|
||||
server = make_server(tmp_path)
|
||||
server.config.port = 0
|
||||
cid = await server.router.new_conversation("U")
|
||||
assert await server.start()
|
||||
try:
|
||||
port = server._site._server.sockets[0].getsockname()[1]
|
||||
headers = {"Authorization": "Bearer test-secret-abc123"}
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"http://127.0.0.1:{port}/attachments",
|
||||
params={"conversation_id": cid},
|
||||
data=b"", headers=headers) as resp:
|
||||
assert resp.status == 400
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_bad_conversation_format(self, tmp_path):
|
||||
aiohttp = pytest.importorskip("aiohttp")
|
||||
server = make_server(tmp_path)
|
||||
server.config.port = 0
|
||||
assert await server.start()
|
||||
try:
|
||||
port = server._site._server.sockets[0].getsockname()[1]
|
||||
headers = {"Authorization": "Bearer test-secret-abc123"}
|
||||
async with aiohttp.ClientSession() as http:
|
||||
async with http.post(
|
||||
f"http://127.0.0.1:{port}/attachments",
|
||||
params={"conversation_id": "bad/id!"},
|
||||
data=b"x", headers=headers) as resp:
|
||||
assert resp.status == 400
|
||||
finally:
|
||||
await server.stop()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# chat.send with attachment_ids
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestChatSendAttachments:
|
||||
def test_user_history_hides_agent_file_context(self):
|
||||
from pheby.hermes_bridge import _display_user_text
|
||||
content = ("[The user sent a text document: 'plan.md'. Its content has been included below.]\n\n"
|
||||
"[Pheby user message]\nreview this\n[/Pheby user message]\n\n"
|
||||
"Attached file: plan.md\n```md\n# private context\n```")
|
||||
assert _display_user_text(content) == "review this"
|
||||
assert _display_user_text("ordinary message") == "ordinary message"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_send_with_text_file_inlines_and_anchors(
|
||||
self, tmp_path):
|
||||
server = make_server(tmp_path)
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
server._clients["t"] = client
|
||||
cid = "a" * 32
|
||||
desc = await server.store.register_bytes(
|
||||
b"# plan\n1. build\n",
|
||||
conversation_id=cid, filename="plan.md",
|
||||
mime_type="text/markdown")
|
||||
from pheby import hermes_bridge as hb
|
||||
await hb.send_chat(server, cid, "review this", client, "r1",
|
||||
attachment_ids=[desc["attachment_id"]])
|
||||
event = server.adapter.handled[0]
|
||||
# Agent receives the file, while the history renderer can recover
|
||||
# just the user's own text from the delimited context.
|
||||
assert "```md" in event.text
|
||||
assert "# plan" in event.text
|
||||
assert event.text.startswith("[Pheby user message]\nreview this\n[/Pheby user message]")
|
||||
# media handed to gateway
|
||||
assert len(event.media_urls) == 1
|
||||
assert Path(event.media_urls[0]).read_bytes() == b"# plan\n1. build\n"
|
||||
assert event.media_types == ["text/markdown"]
|
||||
assert event.media_text_inlined == [True]
|
||||
# anchored to the message id
|
||||
stored = server.store.describe(desc["attachment_id"])
|
||||
assert stored["message_id"] == event.message_id
|
||||
added = [e for e in client.ws.events() if e["type"] == proto.S_ATTACHMENT_ADDED]
|
||||
assert added[-1]["attachment"]["message_id"] == event.message_id
|
||||
assert server.store.list_for_conversation(cid) == [stored]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_send_image_sets_photo_type(self, tmp_path):
|
||||
server = make_server(tmp_path)
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
server._clients["t"] = client
|
||||
cid = "b" * 32
|
||||
desc = await server.store.register_bytes(
|
||||
b"\x89PNG\r\n", conversation_id=cid, filename="pic.png",
|
||||
mime_type="image/png")
|
||||
from pheby import hermes_bridge as hb
|
||||
await hb.send_chat(server, cid, "what is this?", client, "r1",
|
||||
attachment_ids=[desc["attachment_id"]])
|
||||
event = server.adapter.handled[0]
|
||||
assert event.message_type.value == "photo"
|
||||
assert event.media_types == ["image/png"]
|
||||
assert event.media_text_inlined == [False]
|
||||
assert "```" not in event.text
|
||||
from pheby.hermes_bridge import _display_user_text
|
||||
assert _display_user_text(event.text) == "what is this?"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_send_wrong_conversation_rejected(self, tmp_path):
|
||||
server = make_server(tmp_path)
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
server._clients["t"] = client
|
||||
desc = await server.store.register_bytes(
|
||||
b"x", conversation_id="c" * 32, filename="f.bin")
|
||||
from pheby import hermes_bridge as hb
|
||||
await hb.send_chat(server, "d" * 32, "hi", client, "r1",
|
||||
attachment_ids=[desc["attachment_id"]])
|
||||
events = client.ws.events()
|
||||
assert events[0]["type"] == proto.S_ERROR
|
||||
assert events[0]["error"]["code"] == proto.ERR_NOT_FOUND
|
||||
assert server.adapter.handled == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_send_reused_attachment_rejected(self, tmp_path):
|
||||
server = make_server(tmp_path)
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
server._clients["t"] = client
|
||||
cid = "e" * 32
|
||||
desc = await server.store.register_bytes(
|
||||
b"x", conversation_id=cid, filename="f.bin")
|
||||
server.store.anchor_message(desc["attachment_id"], "existing-msg")
|
||||
await server._handle_chat_send(client, {
|
||||
"type": proto.C_CHAT_SEND,
|
||||
"conversation_id": cid,
|
||||
"text": "hi",
|
||||
"attachment_ids": [desc["attachment_id"]],
|
||||
}, "r9")
|
||||
events = client.ws.events()
|
||||
assert events[0]["type"] == proto.S_ERROR
|
||||
assert events[0]["error"]["code"] == proto.ERR_BAD_REQUEST
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_send_attachment_shape_validation(self, tmp_path):
|
||||
server = make_server(tmp_path)
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
cid = "e" * 32
|
||||
await server._handle_chat_send(client, {
|
||||
"type": proto.C_CHAT_SEND,
|
||||
"conversation_id": cid,
|
||||
"text": "hi",
|
||||
"attachment_ids": "not-a-list",
|
||||
}, "r1")
|
||||
ev = client.ws.events()
|
||||
assert ev[0]["error"]["code"] == proto.ERR_BAD_REQUEST
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_send_no_attachments_unchanged(self, tmp_path):
|
||||
server = make_server(tmp_path)
|
||||
client = FakeClientConnection()
|
||||
client.authenticated = True
|
||||
server._clients["t"] = client
|
||||
from pheby import hermes_bridge as hb
|
||||
await hb.send_chat(server, "f" * 32, "plain", client, "r1")
|
||||
event = server.adapter.handled[0]
|
||||
assert event.text == "plain"
|
||||
assert event.media_urls == []
|
||||
assert event.message_type.value == "text"
|
||||
Reference in New Issue
Block a user