mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 16:35:20 +08:00
Exclude message_content and variables from the message source used by conversation session summaries. These fields were carried through window sorting even though the resulting summaries did not use them. Preserve complete payloads for message detail queries. Add regression coverage for summary counts, session metadata, and full message retrieval.
292 lines
12 KiB
Python
292 lines
12 KiB
Python
"""Bot-scoped session regressions exercised against real SQL databases."""
|
|
|
|
import datetime as dt
|
|
import logging
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import sqlalchemy as sa
|
|
from sqlalchemy.ext.asyncio import create_async_engine
|
|
|
|
from langbot.pkg.api.http.context import ExecutionContext
|
|
from langbot.pkg.api.http.service.monitoring import MonitoringService
|
|
from langbot.pkg.entity.persistence.base import Base
|
|
from langbot.pkg.entity.persistence import monitoring as models
|
|
from langbot.pkg.persistence.mgr import PersistenceManager
|
|
from langbot.pkg.pipeline.monitoring_helper import MonitoringHelper
|
|
|
|
from tests.integration.persistence.test_monitoring_postgres import cloud_database # noqa: F401
|
|
|
|
pytestmark = pytest.mark.asyncio
|
|
|
|
|
|
@pytest.mark.asyncio(loop_scope='module')
|
|
async def test_postgres_upgrade_rls_and_concurrent_bot_counts(cloud_database): # noqa: F811
|
|
import asyncio
|
|
import importlib
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
from tests.integration.persistence.test_monitoring_postgres import WORKSPACE_A, _context, _read
|
|
|
|
ap, admin = cloud_database
|
|
service = ap.monitoring_service
|
|
ctx = _context(WORKSPACE_A)
|
|
await service.record_session_start(ctx, session_id='person_42', **resource('a'))
|
|
for bot in ['a', 'b']:
|
|
await service.record_message(ctx, session_id='person_42', message_content=bot, **resource(bot))
|
|
async with admin.begin() as conn:
|
|
|
|
def migrate(connection):
|
|
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
|
with Operations.context(MigrationContext.configure(connection)):
|
|
migration.downgrade()
|
|
migration.upgrade()
|
|
rls = connection.execute(
|
|
sa.text("SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname='monitoring_sessions'")
|
|
).one()
|
|
assert tuple(rls) == (True, True)
|
|
assert (
|
|
connection.execute(
|
|
sa.text("SELECT count(*) FROM pg_policies WHERE tablename='monitoring_sessions'")
|
|
).scalar_one()
|
|
== 1
|
|
)
|
|
|
|
await conn.run_sync(migrate)
|
|
rows, total = await _read(service, 'get_sessions', ctx)
|
|
assert total == 2
|
|
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
|
await asyncio.gather(*[service.record_session_start(ctx, session_id='race', **resource('a')) for _ in range(10)])
|
|
result = await _read(service, 'get_session_analysis', ctx, 'race', bot_id='a')
|
|
assert result['session']['message_count'] == 10
|
|
assert not (await _read(service, 'get_session_analysis', ctx, 'person_42'))['found']
|
|
assert (await _read(service, 'get_session_analysis', ctx, 'person_42', bot_id='b'))['message_stats']['total'] == 1
|
|
|
|
|
|
async def test_migration_reconstructs_collisions_and_preserves_indexes(service):
|
|
import importlib
|
|
from alembic.migration import MigrationContext
|
|
from alembic.operations import Operations
|
|
|
|
engine = service.ap.persistence_mgr.get_db_engine()
|
|
async with engine.begin() as conn:
|
|
|
|
def upgrade(connection):
|
|
table = models.MonitoringSession.__table__
|
|
table.drop(connection)
|
|
metadata = sa.MetaData()
|
|
legacy = table.to_metadata(metadata)
|
|
legacy.primary_key._columns.remove(legacy.c.bot_id)
|
|
legacy.c.bot_id.primary_key = False
|
|
# Resolve the unchanged Workspace FK in copied metadata.
|
|
Base.metadata.tables['workspaces'].to_metadata(metadata)
|
|
legacy.create(connection)
|
|
now = dt.datetime(2026, 1, 1)
|
|
connection.execute(
|
|
sa.insert(legacy).values(
|
|
workspace_uuid='workspace',
|
|
session_id='person_42',
|
|
**resource('a'),
|
|
message_count=99,
|
|
start_time=now,
|
|
last_activity=now,
|
|
is_active=True,
|
|
)
|
|
)
|
|
for bot in ['a', 'b']:
|
|
connection.execute(
|
|
sa.insert(models.MonitoringMessage).values(
|
|
id=bot,
|
|
workspace_uuid='workspace',
|
|
timestamp=now,
|
|
**resource(bot),
|
|
session_id='person_42',
|
|
message_content=bot,
|
|
role='user',
|
|
status='success',
|
|
level='info',
|
|
)
|
|
)
|
|
indexes = {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
|
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0023_bot_scoped_sessions')
|
|
with Operations.context(MigrationContext.configure(connection)):
|
|
migration.upgrade()
|
|
migration.upgrade() # Fresh/already-upgraded schema is safe.
|
|
assert sa.inspect(connection).get_pk_constraint('monitoring_sessions')['constrained_columns'] == [
|
|
'workspace_uuid',
|
|
'bot_id',
|
|
'session_id',
|
|
]
|
|
assert indexes <= {i['name'] for i in sa.inspect(connection).get_indexes('monitoring_sessions')}
|
|
|
|
await conn.run_sync(upgrade)
|
|
rows, total = await service.get_sessions(context())
|
|
assert total == 2
|
|
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 1, 'b': 1}
|
|
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
|
|
|
|
|
def context(bot=None):
|
|
return ExecutionContext(instance_uuid='test', workspace_uuid='workspace', placement_generation=1, bot_uuid=bot)
|
|
|
|
|
|
def resource(bot):
|
|
return dict(bot_id=bot, bot_name=bot, pipeline_id=bot, pipeline_name=bot)
|
|
|
|
|
|
@pytest.fixture
|
|
async def service():
|
|
engine = create_async_engine('sqlite+aiosqlite:///:memory:')
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
class Persistence:
|
|
serialize_model = PersistenceManager.serialize_model
|
|
|
|
def get_db_engine(self):
|
|
return engine
|
|
|
|
async def execute_async(self, stmt):
|
|
async with engine.begin() as conn:
|
|
return await conn.execute(stmt)
|
|
|
|
ap = SimpleNamespace(persistence_mgr=Persistence(), logger=logging.getLogger(__name__))
|
|
ap.monitoring_service = MonitoringService(ap)
|
|
yield ap.monitoring_service
|
|
await engine.dispose()
|
|
|
|
|
|
async def test_helper_first_message_count_and_two_bot_isolation(service):
|
|
for bot in ['a', 'b', 'a']:
|
|
query = SimpleNamespace(
|
|
_execution_context=context(bot),
|
|
launcher_type='person',
|
|
launcher_id=42,
|
|
sender_id=42,
|
|
message_chain=SimpleNamespace(model_dump=lambda: []),
|
|
)
|
|
assert await MonitoringHelper.record_query_start(service.ap, query, **resource(bot))
|
|
rows, total = await service.get_sessions(context())
|
|
assert total == 2
|
|
assert {r['bot_id']: r['message_count'] for r in rows} == {'a': 2, 'b': 1}
|
|
assert {r['pipeline_id'] for r in rows} == {'a', 'b'}
|
|
assert {r['session_id'] for r in rows} == {'person_42'}
|
|
|
|
|
|
async def test_analysis_fails_closed_and_scopes_statistics(service):
|
|
for bot in ['a', 'b']:
|
|
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
|
await service.record_message(context(bot), session_id='person_42', message_content=bot, **resource(bot))
|
|
assert (await service.get_session_analysis(context(), 'person_42'))['found'] is False
|
|
result = await service.get_session_analysis(context(), 'person_42', bot_id='b')
|
|
assert result['message_stats']['total'] == 1
|
|
assert result['session']['bot_id'] == 'b'
|
|
|
|
|
|
async def test_single_message_analysis_without_tool_calls(service):
|
|
from langbot.pkg.api.http.controller.groups.monitoring import validate_monitoring_window
|
|
from werkzeug.exceptions import BadRequest
|
|
|
|
await service.record_session_start(context('a'), session_id='person_42', **resource('a'))
|
|
await service.record_message(context('a'), session_id='person_42', message_content='hi', **resource('a'))
|
|
row = (await service.ap.persistence_mgr.execute_async(sa.select(models.MonitoringMessage.timestamp))).one()
|
|
timestamp = row.timestamp
|
|
validate_monitoring_window(timestamp, timestamp, allow_point_window=True)
|
|
result = await service.get_session_analysis(
|
|
context(), 'person_42', bot_id='a', start_time=timestamp, end_time=timestamp
|
|
)
|
|
assert result['found'] is True
|
|
assert result['message_stats']['total'] == 1
|
|
assert result['tool_calls'] == []
|
|
for start, end in [
|
|
(timestamp + dt.timedelta(seconds=1), timestamp),
|
|
(timestamp, timestamp + dt.timedelta(days=366)),
|
|
]:
|
|
with pytest.raises(BadRequest):
|
|
validate_monitoring_window(start, end, allow_point_window=True)
|
|
with pytest.raises(BadRequest):
|
|
validate_monitoring_window(timestamp, timestamp)
|
|
|
|
|
|
async def test_activity_requires_bot_and_upsert_counts_racing_first_queries(service):
|
|
for _ in range(2):
|
|
await service.record_session_start(context('a'), session_id='person_42', **resource('a'))
|
|
with pytest.raises(ValueError, match='bot'):
|
|
await service.update_session_activity(context(), 'person_42')
|
|
assert await service.update_session_activity(context('a'), 'person_42')
|
|
assert not await service.update_session_activity(context('b'), 'person_42')
|
|
rows, _ = await service.get_sessions(context())
|
|
assert rows[0]['message_count'] == 3
|
|
|
|
|
|
async def test_old_active_sessions_are_listed_exported_and_not_cleaned(service):
|
|
for bot in ['a', 'b']:
|
|
await service.record_session_start(context(bot), session_id='person_42', **resource(bot))
|
|
old = dt.datetime(2000, 1, 1)
|
|
await service.ap.persistence_mgr.execute_async(sa.update(models.MonitoringSession).values(start_time=old))
|
|
await service.ap.persistence_mgr.execute_async(
|
|
sa.update(models.MonitoringSession).where(models.MonitoringSession.bot_id == 'a').values(last_activity=old)
|
|
)
|
|
since = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None) - dt.timedelta(days=1)
|
|
rows, total = await service.get_sessions(context(), start_time=since)
|
|
assert total == 1 and rows[0]['bot_id'] == 'b'
|
|
assert len(await service.export_sessions(context(), start_time=since)) == 1
|
|
count = await service._delete_expired_in_batches(
|
|
context(),
|
|
models.MonitoringSession,
|
|
models.MonitoringSession.last_activity,
|
|
models.MonitoringSession.session_id,
|
|
since,
|
|
1,
|
|
2,
|
|
)
|
|
assert count == 1
|
|
rows, total = await service.get_sessions(context())
|
|
assert total == 1 and rows[0]['bot_id'] == 'b'
|
|
|
|
|
|
async def test_bot_session_summary_never_sorts_inline_message_payloads(service):
|
|
engine = service.ap.persistence_mgr.get_db_engine()
|
|
payload = 'historical inline media ' * 16384
|
|
now = dt.datetime.now(dt.timezone.utc).replace(tzinfo=None)
|
|
async with engine.begin() as connection:
|
|
await connection.execute(
|
|
sa.insert(models.MonitoringMessage),
|
|
[
|
|
dict(
|
|
id=f'message-{index}',
|
|
workspace_uuid='workspace',
|
|
session_id='group_42',
|
|
**resource('a'),
|
|
timestamp=now + dt.timedelta(seconds=index),
|
|
role='user' if index == 0 else 'assistant',
|
|
message_content=payload,
|
|
variables=payload,
|
|
status='success',
|
|
level='info',
|
|
user_id='user',
|
|
user_name='User',
|
|
platform='test',
|
|
)
|
|
for index in range(2)
|
|
],
|
|
)
|
|
|
|
def reject_payload_reads(connection, cursor, statement, parameters, context, executemany):
|
|
assert 'monitoring_messages.message_content' not in statement
|
|
assert 'monitoring_messages.variables' not in statement
|
|
|
|
sa.event.listen(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
|
|
try:
|
|
rows, total = await service.get_sessions(context('a'), bot_ids=['a'])
|
|
finally:
|
|
sa.event.remove(engine.sync_engine, 'before_cursor_execute', reject_payload_reads)
|
|
|
|
assert total == 1
|
|
assert rows[0]['session_id'] == 'group_42'
|
|
assert rows[0]['message_count'] == 2
|
|
assert rows[0]['user_name'] == 'User'
|
|
messages, total = await service.get_messages(context('a'), bot_ids=['a'], session_ids=['group_42'])
|
|
assert total == 2
|
|
assert all(message['message_content'] == payload and message['variables'] == payload for message in messages)
|