diff --git a/dev-docs/platforms/http-bot.md b/dev-docs/platforms/http-bot.md index aa07e162f..1016fca9f 100644 --- a/dev-docs/platforms/http-bot.md +++ b/dev-docs/platforms/http-bot.md @@ -172,7 +172,9 @@ Stitch by `session_id` + `sequence`; the turn is complete when ## 7. Reset a session -Start a fresh conversation for a `session_id` (drops history): +Start a fresh conversation for a `session_id`. This resets Runner conversation +state and hides prior history from future model calls, while retaining monitoring +records. Other sessions are unaffected; an active task must finish before reset. ``` POST /bots/{bot_uuid}/reset diff --git a/src/langbot/pkg/platform/sources/http_bot.py b/src/langbot/pkg/platform/sources/http_bot.py index 72c99d1c8..0aa8ac950 100644 --- a/src/langbot/pkg/platform/sources/http_bot.py +++ b/src/langbot/pkg/platform/sources/http_bot.py @@ -99,6 +99,7 @@ class _SyncCollector: class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): """Standalone HTTP adapter (inbound webhook + outbound callbacks).""" + ap: typing.Any = pydantic.Field(default=None, exclude=True) bot_uuid: str = pydantic.Field(default='', exclude=True) listeners: dict[ @@ -377,7 +378,7 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): return quart.jsonify({'code': 0, 'msg': 'reset', 'data': {'session_id': session_id, 'removed': removed}}), 200 async def _reset_session(self, launcher_type: str, launcher_id: str) -> bool: - """Drop the matching session so the next message starts a fresh conversation.""" + """Reset scoped Runner state and history through the shared context service.""" execution_context = getattr(self.logger, 'execution_context', None) if ( execution_context is None @@ -387,30 +388,13 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): or not self.bot_uuid ): raise RuntimeError('http_bot reset requires a trusted execution scope') - expected_prefix = ( - execution_context.instance_uuid, - execution_context.workspace_uuid, - execution_context.placement_generation, - self.bot_uuid, - launcher_type, - ) - - sess_mgr = self.ap.sess_mgr - before = len(sess_mgr.session_list) - sess_mgr.session_list = [ - s for s in sess_mgr.session_list if not self._matches_session_scope(s, expected_prefix, launcher_id) - ] - return len(sess_mgr.session_list) < before - - @staticmethod - def _matches_session_scope(session, expected_prefix: tuple[str, str, int, str, str], launcher_id: str) -> bool: - session_key = getattr(session, '_langbot_session_key', None) - return ( - isinstance(session_key, tuple) - and len(session_key) == 6 - and session_key[:5] == expected_prefix - and str(session_key[5]) == launcher_id - ) + try: + await self.ap.monitoring_service.reset_session_context( + execution_context, self.bot_uuid, f'{launcher_type}_{launcher_id}' + ) + except LookupError: + return False + return True # -- outbound ------------------------------------------------------------- diff --git a/tests/unit_tests/api/service/test_session_context_reset.py b/tests/unit_tests/api/service/test_session_context_reset.py index 43effa679..70989432f 100644 --- a/tests/unit_tests/api/service/test_session_context_reset.py +++ b/tests/unit_tests/api/service/test_session_context_reset.py @@ -154,3 +154,44 @@ async def test_reset_starts_new_external_runner_conversation(context_service): generation = await get_reset_generation(engine, 'w', 'b', 'person_u') assert isinstance(generation, str) and generation and generation not in {'remote-old', 'person_u'} assert snapshot['conversation']['external.conversation_id'] == generation + + +@pytest.mark.asyncio +async def test_signed_http_reset_uses_normal_adapter_wiring_and_clears_persisted_history(context_service): + import quart + + from langbot.pkg.api.http.context import ExecutionContext + from langbot.pkg.platform.logger import EventLogger + from langbot.pkg.platform.sources.http_bot import HttpBotAdapter + from langbot.pkg.platform.sources import http_bot_signing as signing + + service, engine = context_service + app = SimpleNamespace(monitoring_service=service) + context = ExecutionContext(instance_uuid='instance', workspace_uuid='w', placement_generation=1, bot_uuid='b') + adapter = HttpBotAdapter( + config={'inbound_secret': 'test-secret', 'signature_required': True}, + logger=EventLogger('test', app, context, 'b'), + ) + # Match BotManager's injection path; no synthetic attributes or model_construct. + assert hasattr(adapter, 'ap') + adapter.ap = app + adapter.set_bot_uuid('b') + store = TranscriptStore(engine) + for conversation in ('person_u', 'person_peer'): + await store.append_transcript(None, 'event', conversation, 'user', bot_id='b', workspace_id='w', content='old') + web = quart.Quart(__name__) + body = json.dumps({'session_id': 'u', 'session_type': 'person'}).encode() + timestamp, signature = signing.sign('test-secret', body) + async with web.test_request_context( + '/bots/b/reset', + method='POST', + data=body, + headers={signing.HEADER_TIMESTAMP: timestamp, signing.HEADER_SIGNATURE: signature}, + ): + response, status = await adapter.handle_unified_webhook('b', 'reset', quart.request) + assert status == 200 + assert (await response.get_json())['data'] == {'session_id': 'u', 'removed': True} + assert not (await store.page_transcript('person_u', bot_id='b', workspace_id='w'))[0] + assert (await store.page_transcript('person_peer', bot_id='b', workspace_id='w'))[0] + async with engine.connect() as conn: + assert (await conn.execute(sa.select(sa.func.count()).select_from(Transcript))).scalar() == 2 diff --git a/tests/unit_tests/platform/test_http_bot_tenancy.py b/tests/unit_tests/platform/test_http_bot_tenancy.py index 8e19d143c..35fb2ccbe 100644 --- a/tests/unit_tests/platform/test_http_bot_tenancy.py +++ b/tests/unit_tests/platform/test_http_bot_tenancy.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import time from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest @@ -11,12 +12,6 @@ from langbot.pkg.platform.sources.http_bot import HttpBotAdapter from langbot.pkg.platform.sources import http_bot as http_bot_module -def _session(key): - session = SimpleNamespace() - session._langbot_session_key = key - return session - - def _adapter(app, execution_context) -> HttpBotAdapter: adapter = HttpBotAdapter.model_construct( config={'signature_required': False}, @@ -27,38 +22,28 @@ def _adapter(app, execution_context) -> HttpBotAdapter: sync_waiters={}, inbound_tasks=set(), ) - object.__setattr__(adapter, 'ap', app) + assert hasattr(adapter, 'ap') # BotManager only injects declared application fields. + adapter.ap = app return adapter @pytest.mark.asyncio -async def test_http_bot_reset_removes_only_exact_execution_scope(): +@pytest.mark.parametrize('launcher_type', ['person', 'group']) +async def test_http_bot_reset_uses_trusted_scope_and_persistent_context_service(launcher_type): context = ExecutionContext( - instance_uuid='instance-a', - workspace_uuid='workspace-a', - placement_generation=3, - bot_uuid='bot-a', + instance_uuid='instance-a', workspace_uuid='workspace-a', placement_generation=3, bot_uuid='bot-a' ) - target_key = ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'shared-session') - retained_keys = [ - ('instance-b', 'workspace-a', 3, 'bot-a', 'person', 'shared-session'), - ('instance-a', 'workspace-b', 3, 'bot-a', 'person', 'shared-session'), - ('instance-a', 'workspace-a', 4, 'bot-a', 'person', 'shared-session'), - ('instance-a', 'workspace-a', 3, 'bot-b', 'person', 'shared-session'), - ('instance-a', 'workspace-a', 3, 'bot-a', 'group', 'shared-session'), - ('instance-a', 'workspace-a', 3, 'bot-a', 'person', 'other-session'), - ] - sessions = [_session(target_key), *[_session(key) for key in retained_keys], SimpleNamespace()] - app = SimpleNamespace(sess_mgr=SimpleNamespace(session_list=sessions)) + reset = AsyncMock(return_value={'reset': True}) + app = SimpleNamespace(monitoring_service=SimpleNamespace(reset_session_context=reset)) adapter = _adapter(app, context) - removed = await adapter._reset_session('person', 'shared-session') - - assert removed is True - assert [getattr(session, '_langbot_session_key', None) for session in app.sess_mgr.session_list] == [ - *retained_keys, - None, - ] + assert await adapter._reset_session(launcher_type, 'shared-session') is True + reset.assert_awaited_once_with(context, 'bot-a', f'{launcher_type}_shared-session') + reset.side_effect = LookupError('Session not found') + assert await adapter._reset_session(launcher_type, 'missing') is False + reset.side_effect = RuntimeError('Session is processing a task') + with pytest.raises(RuntimeError, match='processing'): + await adapter._reset_session(launcher_type, 'busy') @pytest.mark.asyncio