455 lines
16 KiB
Python
455 lines
16 KiB
Python
"""Per-message actor attribution on conversation items.
|
|
|
|
Mirrors the comment ``created_by`` contract for conversation items:
|
|
the human actor who posts a message is recorded on the item and is
|
|
visible — distinct per actor — in the session items API. Agent/system
|
|
items carry no actor and stay distinguishable.
|
|
|
|
Coverage spans the route helper that stamps the actor, the end-to-end
|
|
``POST /events`` and ``initial_items`` paths (runner stubbed so no real
|
|
runner is needed), and the GET read path that surfaces it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import AsyncIterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
import pytest
|
|
import pytest_asyncio
|
|
from fastapi import FastAPI
|
|
|
|
from omnigent.entities import MessageData, NewConversationItem
|
|
from omnigent.runtime.agent_cache import AgentCache
|
|
from omnigent.server.app import create_app
|
|
from omnigent.server.auth import LEVEL_EDIT
|
|
from omnigent.server.routes._auth_helpers import attribution_user
|
|
from omnigent.server.routes.sessions import _build_new_item
|
|
from omnigent.server.schemas import SessionEventInput
|
|
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
|
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
|
from omnigent.stores.comment_store.sqlalchemy_store import SqlAlchemyCommentStore
|
|
from omnigent.stores.conversation_store.sqlalchemy_store import (
|
|
SqlAlchemyConversationStore,
|
|
)
|
|
from omnigent.stores.file_store.sqlalchemy_store import SqlAlchemyFileStore
|
|
from omnigent.stores.permission_store.sqlalchemy_store import (
|
|
SqlAlchemyPermissionStore,
|
|
)
|
|
from tests.server.conftest import ControllableMockClient
|
|
from tests.server.helpers import create_test_agent
|
|
|
|
# ── Route helper: actor is stamped onto the new item ─────────────────────────
|
|
|
|
|
|
def test_build_new_item_stamps_created_by() -> None:
|
|
"""``_build_new_item`` threads the posting actor onto the item."""
|
|
body = SessionEventInput(
|
|
type="message",
|
|
data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
)
|
|
item = _build_new_item(body, "resp_1", created_by="bob@example.com")
|
|
assert item.created_by == "bob@example.com"
|
|
|
|
|
|
def test_build_new_item_defaults_created_by_none() -> None:
|
|
"""Single-user mode (no actor) leaves ``created_by`` unset."""
|
|
body = SessionEventInput(
|
|
type="message",
|
|
data={"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
)
|
|
item = _build_new_item(body, "resp_1")
|
|
assert item.created_by is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_id,expected",
|
|
[
|
|
# The single-user fallback sentinel is not a distinct actor.
|
|
("local", None),
|
|
# A real authenticated identity passes through unchanged.
|
|
("alice@example.com", "alice@example.com"),
|
|
# Auth disabled (no provider) already yields None.
|
|
(None, None),
|
|
],
|
|
)
|
|
def test_attribution_user_normalizes_local_sentinel(
|
|
user_id: str | None, expected: str | None
|
|
) -> None:
|
|
"""``attribution_user`` drops the reserved ``"local"`` identity.
|
|
|
|
A non-``None`` return for the ``"local"`` case would mean the route
|
|
records ``created_by="local"``, which the UI renders as a spurious
|
|
author label on every bubble in a single-user session.
|
|
"""
|
|
assert attribution_user(user_id) == expected
|
|
|
|
|
|
# ── GET read path: API surfaces per-actor attribution ────────────────────────
|
|
|
|
|
|
def _seed_shared_session(db_uri: str, grants: dict[str, int]) -> str:
|
|
"""Create a conversation and grant access to each user."""
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
conversation = conv_store.create_conversation()
|
|
perm_store = SqlAlchemyPermissionStore(db_uri)
|
|
for user_email, level in grants.items():
|
|
perm_store.ensure_user(user_email)
|
|
perm_store.grant(user_email, conversation.id, level)
|
|
return conversation.id
|
|
|
|
|
|
def _seed_items(db_uri: str, session_id: str) -> None:
|
|
"""Append owner, collaborator, and agent items to the session."""
|
|
conv_store = SqlAlchemyConversationStore(db_uri)
|
|
conv_store.append(
|
|
session_id,
|
|
[
|
|
NewConversationItem(
|
|
type="message",
|
|
response_id="resp_owner",
|
|
data=MessageData(
|
|
role="user",
|
|
content=[{"type": "input_text", "text": "owner asks"}],
|
|
),
|
|
created_by="alice@example.com",
|
|
),
|
|
NewConversationItem(
|
|
type="message",
|
|
response_id="resp_collab",
|
|
data=MessageData(
|
|
role="user",
|
|
content=[{"type": "input_text", "text": "collaborator asks"}],
|
|
),
|
|
created_by="bob@example.com",
|
|
),
|
|
NewConversationItem(
|
|
type="message",
|
|
response_id="resp_agent",
|
|
data=MessageData(
|
|
role="assistant",
|
|
agent="coding-agent",
|
|
content=[{"type": "output_text", "text": "done"}],
|
|
),
|
|
),
|
|
],
|
|
)
|
|
|
|
|
|
@pytest.fixture()
|
|
def auth_app(runtime_init: None, db_uri: str, tmp_path: Path) -> FastAPI:
|
|
"""App with ``permission_store`` enabled so auth is active.
|
|
|
|
Uses header-mode auth so tests can authenticate via
|
|
``X-Forwarded-Email`` without needing cookie infrastructure.
|
|
``local_single_user=True`` models the single-user local runtime,
|
|
so headerless requests resolve to the reserved ``"local"``
|
|
sentinel (exercised by the attribution tests below) instead of
|
|
being rejected with 401 as on a deployed multi-user server.
|
|
"""
|
|
from omnigent.server.auth import UnifiedAuthProvider
|
|
|
|
artifact_store = LocalArtifactStore(str(tmp_path / "artifacts"))
|
|
return create_app(
|
|
agent_store=SqlAlchemyAgentStore(db_uri),
|
|
file_store=SqlAlchemyFileStore(db_uri),
|
|
conversation_store=SqlAlchemyConversationStore(db_uri),
|
|
artifact_store=artifact_store,
|
|
agent_cache=AgentCache(
|
|
artifact_store=artifact_store,
|
|
cache_dir=tmp_path / "cache",
|
|
),
|
|
comment_store=SqlAlchemyCommentStore(db_uri),
|
|
permission_store=SqlAlchemyPermissionStore(db_uri),
|
|
auth_provider=UnifiedAuthProvider(source="header", local_single_user=True),
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture()
|
|
async def auth_client(
|
|
auth_app: FastAPI,
|
|
mock_llm: ControllableMockClient,
|
|
tmp_path: Path,
|
|
) -> AsyncIterator[httpx.AsyncClient]:
|
|
"""Async HTTP client wired to the auth-enabled app."""
|
|
from omnigent.runtime import set_harness_process_manager
|
|
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
|
|
|
pm = HarnessProcessManager(tmp_parent=tmp_path / "harness_pm")
|
|
await pm.start()
|
|
set_harness_process_manager(pm)
|
|
|
|
transport = httpx.ASGITransport(app=auth_app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
yield c
|
|
|
|
mock_llm.release_all()
|
|
set_harness_process_manager(None)
|
|
await pm.shutdown()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_items_expose_per_actor_attribution(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""GET items distinguishes owner, collaborator, and agent messages.
|
|
|
|
A collaborator reading the shared session sees both human messages
|
|
attributed to their actual authors (distinct values) and the agent
|
|
message with no ``created_by`` key.
|
|
"""
|
|
session_id = _seed_shared_session(
|
|
db_uri,
|
|
{"alice@example.com": LEVEL_EDIT, "bob@example.com": LEVEL_EDIT},
|
|
)
|
|
_seed_items(db_uri, session_id)
|
|
|
|
# Bob (a collaborator) reads the session.
|
|
resp = await auth_client.get(
|
|
f"/v1/sessions/{session_id}/items",
|
|
headers={"X-Forwarded-Email": "bob@example.com"},
|
|
)
|
|
resp.raise_for_status()
|
|
items = resp.json()["data"]
|
|
assert len(items) == 3
|
|
|
|
by_resp = {it["response_id"]: it for it in items}
|
|
assert by_resp["resp_owner"]["created_by"] == "alice@example.com"
|
|
assert by_resp["resp_collab"]["created_by"] == "bob@example.com"
|
|
# Owner and collaborator are distinguishable, not collapsed.
|
|
assert by_resp["resp_owner"]["created_by"] != by_resp["resp_collab"]["created_by"]
|
|
# Agent message carries no human actor.
|
|
assert "created_by" not in by_resp["resp_agent"]
|
|
|
|
|
|
# ── End-to-end: the route stamps the authenticated poster ────────────────────
|
|
|
|
|
|
class _CaptureRunnerClient:
|
|
"""Stub runner client that accepts the forwarded event POST."""
|
|
|
|
async def post(self, path: str, *, json: dict[str, Any], **_: Any) -> Any:
|
|
"""Return a fake 202 so persist-before-forward completes."""
|
|
|
|
class _Resp:
|
|
status_code = 202
|
|
headers: dict[str, str] = {}
|
|
text = ""
|
|
|
|
return _Resp()
|
|
|
|
async def get(self, *_: Any, **__: Any) -> Any:
|
|
raise NotImplementedError
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_event_records_authenticated_poster(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""``POST /events`` persists the item with the caller's identity.
|
|
|
|
The runner is stubbed (persist-before-forward still runs), so this
|
|
pins the one line that connects ``X-Forwarded-Email`` to the
|
|
persisted ``created_by``. If the route stopped threading the
|
|
user_id, the read-back below would be ``None``.
|
|
"""
|
|
from omnigent.server.routes import sessions as sessions_mod
|
|
|
|
async def _stub(*_: Any, **__: Any) -> _CaptureRunnerClient:
|
|
return _CaptureRunnerClient()
|
|
|
|
monkeypatch.setattr(sessions_mod, "_get_runner_client", _stub)
|
|
|
|
session_id = _seed_shared_session(db_uri, {"alice@example.com": LEVEL_EDIT})
|
|
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"data": {"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
},
|
|
headers={"X-Forwarded-Email": "alice@example.com"},
|
|
)
|
|
assert resp.status_code == 202, resp.text
|
|
|
|
items = await asyncio.to_thread(SqlAlchemyConversationStore(db_uri).list_items, session_id)
|
|
[persisted] = items.data
|
|
assert persisted.created_by == "alice@example.com"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_input_consumed_event_carries_created_by(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The live ``session.input.consumed`` event carries the poster.
|
|
|
|
A collaborator watching the stream must be able to attribute a
|
|
just-arrived message without waiting for a hydration refresh, so the
|
|
event payload mirrors the persisted item's ``created_by``. If the
|
|
emit stopped threading ``item.created_by``, the captured payload
|
|
would omit it (``None``) and the other client's bubble would stay
|
|
unlabeled until refresh.
|
|
"""
|
|
from omnigent.server.routes import sessions as sessions_mod
|
|
|
|
async def _stub(*_: Any, **__: Any) -> _CaptureRunnerClient:
|
|
return _CaptureRunnerClient()
|
|
|
|
monkeypatch.setattr(sessions_mod, "_get_runner_client", _stub)
|
|
|
|
published: list[dict[str, Any]] = []
|
|
|
|
def _capture(_session_id: str, event: dict[str, Any]) -> None:
|
|
published.append(event)
|
|
|
|
monkeypatch.setattr(sessions_mod.session_stream, "publish", _capture)
|
|
|
|
session_id = _seed_shared_session(db_uri, {"alice@example.com": LEVEL_EDIT})
|
|
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "message",
|
|
"data": {"role": "user", "content": [{"type": "input_text", "text": "hi"}]},
|
|
},
|
|
headers={"X-Forwarded-Email": "alice@example.com"},
|
|
)
|
|
assert resp.status_code == 202, resp.text
|
|
|
|
consumed = [e for e in published if e["type"] == "session.input.consumed"]
|
|
assert len(consumed) == 1, f"expected one consumed event, got {[e['type'] for e in published]}"
|
|
# The author rides on the payload level (beside item_id/type), matching
|
|
# the client parser and the GET items contract.
|
|
assert consumed[0]["data"]["created_by"] == "alice@example.com"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_user_local_actor_not_attributed(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Single-user mode (``"local"`` identity) leaves messages unattributed.
|
|
|
|
``auth_app`` runs with header-mode auth, so a request with
|
|
no ``X-Forwarded-Email`` header resolves to the reserved ``"local"``
|
|
sentinel. That sentinel is not a distinct human actor, so the route
|
|
must record ``created_by=None`` (not the literal ``"local"``);
|
|
otherwise the UI labels every bubble ``"local"``.
|
|
"""
|
|
agent = await create_test_agent(auth_client)
|
|
|
|
resp = await auth_client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"agent_id": agent["id"],
|
|
"initial_items": [
|
|
{
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "kick off"}],
|
|
},
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
session_id = resp.json()["id"]
|
|
|
|
items = await asyncio.to_thread(SqlAlchemyConversationStore(db_uri).list_items, session_id)
|
|
[persisted] = items.data
|
|
assert persisted.created_by is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initial_items_record_creator(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Session-creation ``initial_items`` carry the creator's identity.
|
|
|
|
No runner is bound, so the items take the history-only seed branch.
|
|
Each seeded item must record the authenticated creator.
|
|
"""
|
|
agent = await create_test_agent(auth_client, user="alice@example.com")
|
|
|
|
resp = await auth_client.post(
|
|
"/v1/sessions",
|
|
json={
|
|
"agent_id": agent["id"],
|
|
"initial_items": [
|
|
{
|
|
"type": "message",
|
|
"data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "kick off"}],
|
|
},
|
|
}
|
|
],
|
|
},
|
|
headers={"X-Forwarded-Email": "alice@example.com"},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
session_id = resp.json()["id"]
|
|
|
|
items = await asyncio.to_thread(SqlAlchemyConversationStore(db_uri).list_items, session_id)
|
|
[persisted] = items.data
|
|
assert persisted.created_by == "alice@example.com"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_external_conversation_item_direct_terminal_attributes_request_actor(
|
|
auth_client: httpx.AsyncClient,
|
|
db_uri: str,
|
|
) -> None:
|
|
"""Direct terminal input stamps the forwarder's authenticated identity.
|
|
|
|
When the transcript forwarder POSTs ``external_conversation_item``
|
|
for a message typed directly in the native terminal (no prior web-UI
|
|
POST, so no pending-input entry exists), the server must attribute the
|
|
item to whoever is authenticated on the forwarder request. Before the
|
|
fix, ``_persist_external_conversation_item`` received no ``created_by``
|
|
argument, so items persisted with ``None`` and the author label never
|
|
appeared in the web UI for terminal-typed messages.
|
|
"""
|
|
from omnigent.runtime import pending_inputs
|
|
|
|
session_id = _seed_shared_session(db_uri, {"alice@example.com": LEVEL_EDIT})
|
|
|
|
# Ensure no stale pending entry exists for this session — the fix only
|
|
# fires on the no-pending-entry branch, so a leftover entry would mask
|
|
# the regression we're guarding against.
|
|
pending_inputs.reset_for_tests()
|
|
|
|
resp = await auth_client.post(
|
|
f"/v1/sessions/{session_id}/events",
|
|
json={
|
|
"type": "external_conversation_item",
|
|
"data": {
|
|
"item_type": "message",
|
|
"item_data": {
|
|
"role": "user",
|
|
"content": [{"type": "input_text", "text": "hi from terminal"}],
|
|
},
|
|
"response_id": "resp_terminal_1",
|
|
},
|
|
},
|
|
headers={"X-Forwarded-Email": "alice@example.com"},
|
|
)
|
|
assert resp.status_code == 202, resp.text
|
|
|
|
items = await asyncio.to_thread(SqlAlchemyConversationStore(db_uri).list_items, session_id)
|
|
[persisted] = items.data
|
|
# The forwarder authenticates as alice — the item must carry her email.
|
|
# If this is None, _persist_external_conversation_item stopped reading
|
|
# created_by from the request and direct terminal typing has no author label.
|
|
assert persisted.created_by == "alice@example.com"
|