mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-23 04:46:07 +00:00
feat(agent): integrate structured runner interactions
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
"""Tests for Host-owned structured interaction persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
|
||||
import pytest
|
||||
import sqlalchemy
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from langbot.pkg.agent.runner.interaction_store import (
|
||||
DuplicateInteractionError,
|
||||
InteractionAlreadySubmittedError,
|
||||
InteractionExpiredError,
|
||||
InteractionScopeError,
|
||||
InteractionStore,
|
||||
)
|
||||
from langbot.pkg.entity.persistence.agent_interaction import AgentInteraction
|
||||
from langbot.pkg.entity.persistence.base import Base
|
||||
|
||||
|
||||
UTC = datetime.timezone.utc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_engine(tmp_path):
|
||||
engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}', echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
yield engine
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(db_engine):
|
||||
return InteractionStore(db_engine)
|
||||
|
||||
|
||||
async def _create(store: InteractionStore, **overrides):
|
||||
values = {
|
||||
'interaction_id': 'form-1',
|
||||
'run_id': 'run-1',
|
||||
'binding_id': 'binding-1',
|
||||
'runner_id': 'plugin:test/ApprovalRunner/default',
|
||||
'processor_type': 'pipeline',
|
||||
'processor_id': 'pipeline-1',
|
||||
'request': {'interaction_id': 'form-1', 'title': 'Approve?', 'fallback_text': 'Reply yes or no.'},
|
||||
'delivery_target': {'chat_id': 'chat-1'},
|
||||
'bot_id': 'bot-1',
|
||||
'conversation_id': 'group_chat-1',
|
||||
'actor_id': 'user-1',
|
||||
'expires_at': int((datetime.datetime.now(UTC) + datetime.timedelta(minutes=5)).timestamp()),
|
||||
}
|
||||
values.update(overrides)
|
||||
return await store.create_request(**values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_request_returns_token_but_persists_only_hash(store, db_engine):
|
||||
record, token = await _create(store)
|
||||
|
||||
assert record['status'] == 'pending'
|
||||
assert record['processor_type'] == 'pipeline'
|
||||
assert token
|
||||
|
||||
async with db_engine.connect() as conn:
|
||||
result = await conn.execute(sqlalchemy.select(AgentInteraction.callback_token_hash))
|
||||
stored_hash = result.scalar_one()
|
||||
|
||||
assert stored_hash == hashlib.sha256(token.encode()).hexdigest()
|
||||
assert stored_hash != token
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submission_is_scope_checked_and_consumed_once(store):
|
||||
_, token = await _create(store)
|
||||
|
||||
submitted = await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {'name': 'Alice'}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
assert submitted['status'] == 'submitted'
|
||||
assert submitted['submission']['action_id'] == 'approve'
|
||||
assert submitted['submission']['values'] == {'name': 'Alice'}
|
||||
|
||||
with pytest.raises(InteractionAlreadySubmittedError):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_result_can_be_resolved_for_scoped_update(store):
|
||||
_, token = await _create(store)
|
||||
assert await store.record_delivery_success(
|
||||
'run-1',
|
||||
'form-1',
|
||||
{'message_id': 'message-1', 'rich': True},
|
||||
)
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
assert await store.record_delivery_success(
|
||||
'run-1',
|
||||
'form-1',
|
||||
{'message_id': 'message-1', 'card_id': 'card-1', 'sequence': 1, 'rich': True},
|
||||
)
|
||||
|
||||
target = await store.find_update_target(
|
||||
interaction_id='form-1',
|
||||
binding_id='binding-1',
|
||||
runner_id='plugin:test/ApprovalRunner/default',
|
||||
processor_type='pipeline',
|
||||
processor_id='pipeline-1',
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
assert target['delivery_result'] == {
|
||||
'message_id': 'message-1',
|
||||
'card_id': 'card-1',
|
||||
'sequence': 1,
|
||||
'rich': True,
|
||||
}
|
||||
|
||||
assert (
|
||||
await store.find_update_target(
|
||||
interaction_id='form-1',
|
||||
binding_id='binding-1',
|
||||
runner_id='plugin:test/ApprovalRunner/default',
|
||||
processor_type='pipeline',
|
||||
processor_id='pipeline-1',
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='other-user',
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scope_mismatch_does_not_consume_request(store):
|
||||
_, token = await _create(store)
|
||||
|
||||
with pytest.raises(InteractionScopeError, match='actor'):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='other-user',
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'pending'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_request_is_marked_terminal(store):
|
||||
_, token = await _create(store)
|
||||
cutoff = datetime.datetime.now(UTC) + datetime.timedelta(minutes=10)
|
||||
assert await store.expire_pending(now=cutoff) == 1
|
||||
|
||||
with pytest.raises(InteractionExpiredError):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'expired'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forged_old_submission_time_cannot_bypass_server_expiry(store, db_engine):
|
||||
_, token = await _create(store)
|
||||
expired_at = datetime.datetime.now(UTC) - datetime.timedelta(minutes=1)
|
||||
async with db_engine.begin() as conn:
|
||||
await conn.execute(
|
||||
sqlalchemy.update(AgentInteraction).where(AgentInteraction.run_id == 'run-1').values(expires_at=expired_at)
|
||||
)
|
||||
|
||||
with pytest.raises(InteractionExpiredError):
|
||||
await store.consume_submission(
|
||||
callback_token=token,
|
||||
submission={'interaction_id': 'form-1', 'values': {}},
|
||||
bot_id='bot-1',
|
||||
conversation_id='group_chat-1',
|
||||
actor_id='user-1',
|
||||
submitted_at=int((expired_at - datetime.timedelta(minutes=1)).timestamp()),
|
||||
)
|
||||
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'expired'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_interaction_id_is_unique_within_run_only(store):
|
||||
await _create(store)
|
||||
|
||||
with pytest.raises(DuplicateInteractionError):
|
||||
await _create(store)
|
||||
|
||||
second, _ = await _create(store, run_id='run-2')
|
||||
assert second['interaction_id'] == 'form-1'
|
||||
assert second['run_id'] == 'run-2'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expire_pending_updates_only_elapsed_rows(store):
|
||||
now = datetime.datetime.now(UTC)
|
||||
await _create(
|
||||
store,
|
||||
interaction_id='expired',
|
||||
run_id='run-expired',
|
||||
expires_at=int((now + datetime.timedelta(minutes=5)).timestamp()),
|
||||
)
|
||||
await _create(
|
||||
store,
|
||||
interaction_id='future',
|
||||
run_id='run-future',
|
||||
expires_at=int((now + datetime.timedelta(minutes=20)).timestamp()),
|
||||
)
|
||||
cutoff = datetime.datetime.now(UTC) + datetime.timedelta(minutes=10)
|
||||
|
||||
expired = await store.expire_pending(now=cutoff)
|
||||
|
||||
assert expired == 1
|
||||
assert (await store.get_request('run-expired', 'expired'))['status'] == 'expired'
|
||||
assert (await store.get_request('run-future', 'future'))['status'] == 'pending'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delivery_failure_is_terminal(store):
|
||||
await _create(store)
|
||||
|
||||
assert await store.mark_delivery_failed('run-1', 'form-1', 'adapter rejected card')
|
||||
record = await store.get_request('run-1', 'form-1')
|
||||
assert record is not None
|
||||
assert record['status'] == 'delivery_failed'
|
||||
assert record['status_reason'] == 'adapter rejected card'
|
||||
Reference in New Issue
Block a user