mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-28 11:25:35 +08:00
fix(http-bot): reset persistent conversation context through shared service (#2788)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user