fix(http-bot): reset persistent conversation context through shared service (#2788)

This commit is contained in:
huanghuoguoguo
2026-10-28 10:29:17 +08:00
committed by GitHub
parent 0c99832298
commit 82eef2496a
4 changed files with 68 additions and 56 deletions
@@ -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