Files
LangBot/tools/regression_plugin_cases.py
T

249 lines
11 KiB
Python

"""Local plugin business checks through signed Bot ingress and the Page API."""
from contextlib import closing
import copy
import json
from pathlib import Path
from uuid import uuid4
from regression_coverage import checked
from regression_lifecycle_cases import ledger
from regression_local_cases import api, require
from regression_resource_cases import cleanup, journal
from regression_smoke import check_mock_requests, signed_headers
from regression_suite import resolve
GROUP = 'plugins'
CASE_NAMES = {'plugins.command_version', 'plugins.faq_crud'}
COMMAND_PLUGINS = [
{'author': 'langbot-team', 'name': name}
for name in ('LocalAgent', 'LangRAG', 'GeneralParsers', 'EssentialCommands')
]
FAQ_API = '/api/v1/plugins/RockChinQ/FAQManager/page-api'
def add_cases(profile):
pipeline = copy.deepcopy(next(item for item in profile['resources'] if item['id'] == 'chat_pipeline'))
pipeline['id'] = 'plugin_command_pipeline'
pipeline['body']['name'] = 'regression plugin commands'
pipeline['body']['extensions_preferences'] = {
'enable_all_plugins': False,
'plugins': copy.deepcopy(COMMAND_PLUGINS),
}
profile['resources'].append(pipeline)
for name, expected in (
('plugins.command_version', 'LANGBOT_PLUGIN_COMMAND_OK'),
('plugins.faq_crud', 'LANGBOT_PLUGIN_FAQ_OK'),
):
profile['cases'].append({'name': name, 'scenario': name, 'stage': 'plugins', 'expect': expected})
def data(suite, method, path, body=None, *, headers=None):
status, response = api(suite, method, path, body, headers=headers)
require(
status == 200 and isinstance(response, dict) and response.get('code') == 0,
f'{method} {path} did not return a successful response (HTTP {status})',
)
result = response.get('data')
if method == 'DELETE' and result is None:
return {}
require(isinstance(result, dict), f'{method} {path} returned malformed data')
return result
def command_version(suite, case, row):
pipeline = data(suite, 'GET', f'/api/v1/pipelines/{suite.variables["plugin_command_pipeline"]}')['pipeline']
preferences = pipeline.get('extensions_preferences', {})
checked(
row,
'command_plugin_bindings',
lambda: require(
preferences.get('enable_all_plugins') is False and preferences.get('plugins') == COMMAND_PLUGINS,
'Command pipeline must bind only the three base plugins and EssentialCommands',
),
)
version = data(suite, 'GET', '/api/v1/system/info').get('version')
require(isinstance(version, str) and bool(version), 'Core did not report its version')
records = Path(suite.path(suite.profile['smoke']['mock_records']))
require(records.is_file(), 'Command model-call evidence requires the dedicated mock recording file')
body = resolve(
copy.deepcopy(next(item['body'] for item in suite.profile['resources'] if item['id'] == 'chat_bot')),
suite.variables,
)
body['name'] = 'qa-plugin-command-' + uuid4().hex
body['adapter_config']['default_session_type'] = 'person'
body['event_bindings'] = [
{
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': suite.variables['plugin_command_pipeline'],
'enabled': True,
'filters': [],
}
]
body['plugin_processors'] = []
owned = {'kind': 'bot', 'name': body['name'], 'state': 'creating'}
row.setdefault('owned_resources', []).append(owned)
journal(suite, row)
def delete_bot():
if 'uuid' not in owned:
bots = data(suite, 'GET', '/api/v1/platform/bots')['bots']
matches = [bot for bot in bots if bot.get('name') == owned['name']]
require(len(matches) == 1, 'Created command Bot identity is unresolved; inspect the resource journal')
owned['uuid'] = matches[0]['uuid']
journal(suite, row)
path = f'/api/v1/platform/bots/{owned["uuid"]}'
status, response = api(suite, 'GET', path)
if status != 404:
require(
status == 200
and response.get('code') == 0
and response.get('data', {}).get('bot', {}).get('name') == owned['name'],
'Cannot confirm ownership of the command Bot before cleanup',
)
data(suite, 'DELETE', path)
status, _ = api(suite, 'GET', path)
require(status == 404, 'Command Bot remains visible after cleanup')
owned['state'] = 'deleted'
try:
created = data(suite, 'POST', '/api/v1/platform/bots', body)
owned.update(uuid=created['uuid'], state='created')
journal(suite, row)
session = 'qa-plugin-version-' + uuid4().hex
payload = {
'session_id': session,
'session_type': 'person',
'message': [{'type': 'Plain', 'text': '!version'}],
}
offset = records.stat().st_size
reply = data(
suite,
'POST',
f'/bots/{owned["uuid"]}/sync',
payload,
headers=signed_headers(json.dumps(payload).encode(), body['adapter_config']['inbound_secret']),
)
row['command_response'] = reply
parts = reply.get('message')
checked(
row,
'command_version',
lambda: require(
reply.get('session_id') == session
and bool(reply.get('reply_to'))
and isinstance(parts, list)
and all(isinstance(part, dict) and part.get('type') == 'Plain' for part in parts)
and ''.join(part.get('text', '') for part in parts) == f'[bot] LangBot Version: {version}',
'Version command did not return the exact Core version in the originating session',
),
)
with closing(ledger(suite)) as connection:
count = connection.execute(
'SELECT COUNT(*) FROM agent_run WHERE workspace_id=? AND bot_id=? AND id>?',
(suite.variables['workspace_uuid'], owned['uuid'], row['after_id']),
).fetchone()[0]
checked(row, 'command_no_run', lambda: require(count == 0, 'Version command unexpectedly created an AgentRun'))
checked(
row,
'command_no_model',
lambda: check_mock_requests(records, offset, [], timeout=suite.timeout(3)),
)
return {'result': case['expect'], 'version': version, 'agent_runs': count, 'model_requests': 0}
finally:
cleanup(suite, row, 'command_bot_cleanup', delete_bot)
def faq_crud(suite, case, row):
def page(method, endpoint, body=None):
return data(
suite, 'POST', FAQ_API, {'page_id': 'manager', 'endpoint': endpoint, 'method': method, 'body': body}
)
def entries():
values = page('GET', '/entries').get('entries')
require(
isinstance(values, list) and all(isinstance(item, dict) and item.get('id') for item in values),
'FAQ entries response is malformed',
)
return values
previous = {item['id'] for item in entries()}
marker = 'qa-faq-' + uuid4().hex
question, before, after = marker + '-question', marker + '-before', marker + '-after'
owned = {'kind': 'faq-entry', 'question': question, 'state': 'creating'}
row.setdefault('owned_resources', []).append(owned)
journal(suite, row)
def expect_entry(values, answer):
expected = {'id': owned['id'], 'question': question, 'answer': answer}
require(values == [expected], 'FAQ content or entry identity differs from the submitted value')
def delete_entry():
current = entries()
if 'id' not in owned:
matches = [item for item in current if item.get('question') == question and item['id'] not in previous]
require(len(matches) <= 1, 'Created FAQ identity is ambiguous; inspect the resource journal')
if not matches:
owned['state'] = 'absent'
return
owned['id'] = matches[0]['id']
journal(suite, row)
require(owned['id'] not in previous, 'Refusing to delete a pre-existing FAQ entry')
matches = [item for item in current if item['id'] == owned['id']]
if matches:
require(
len(matches) == 1 and matches[0].get('question') == question, 'FAQ ownership changed before cleanup'
)
deleted = page('DELETE', '/entries', {'id': owned['id']})
require(deleted.get('deleted') == owned['id'], 'FAQ deletion returned a different entry ID')
require(not any(item['id'] == owned['id'] for item in entries()), 'Deleted FAQ remains visible')
require(page('POST', '/search', {'query': marker}).get('results') == [], 'Deleted FAQ remains searchable')
owned['state'] = 'deleted'
try:
created = page('POST', '/entries', {'question': question, 'answer': before}).get('entry')
require(isinstance(created, dict) and bool(created.get('id')), 'FAQ creation did not return an entry ID')
owned.update(id=created['id'], state='created')
journal(suite, row)
require(owned['id'] not in previous, 'FAQ creation reused a pre-existing entry ID')
checked(row, 'faq_create', lambda: expect_entry([created], before))
checked(
row, 'faq_read', lambda: expect_entry([item for item in entries() if item['id'] == owned['id']], before)
)
checked(
row, 'faq_search', lambda: expect_entry(page('POST', '/search', {'query': before}).get('results'), before)
)
updated = page('PUT', '/entries', {'id': owned['id'], 'question': question, 'answer': after}).get('entry')
checked(row, 'faq_update', lambda: expect_entry([updated], after))
checked(
row,
'faq_update_read',
lambda: expect_entry([item for item in entries() if item['id'] == owned['id']], after),
)
checked(
row,
'faq_update_search',
lambda: expect_entry(page('POST', '/search', {'query': after}).get('results'), after),
)
checked(
row,
'faq_old_answer_absent',
lambda: require(
page('POST', '/search', {'query': before}).get('results') == [], 'FAQ search returned stale content'
),
)
checked(row, 'faq_delete', delete_entry)
return {'result': case['expect'], 'entry_id': owned['id'], 'deleted': True}
finally:
if owned['state'] != 'deleted':
cleanup(suite, row, 'faq_cleanup', delete_entry)
else:
journal(suite, row)
def run_case(suite, case, row):
require(case['scenario'] in CASE_NAMES, 'Unknown plugin scenario')
return (command_version if case['scenario'] == 'plugins.command_version' else faq_crud)(suite, case, row)