feat: collect beta-only adapter acceptance evidence

This commit is contained in:
Hyu
2026-09-15 20:40:03 +08:00
parent 7c1445051b
commit f7cfb23480
20 changed files with 426 additions and 11 deletions
+30
View File
@@ -0,0 +1,30 @@
# Beta 适配器诊断开发说明
此诊断用于 4.11 适配器事件与 API 的 Beta 验收。仅受支持的 Beta 版本启用;稳定版、Alpha、RC、开发/本地标记版本以及关闭遥测或 Beta 诊断的实例不采集、不上传。
## 采集位置
- `pkg/platform/botmgr.py`:完成适配器事件监听器注册后产生能力快照。`listener_registered` 表示注册完成,不代表网络连接可用。
- 各适配器的 `_dispatch_eba_event`:调用 `diagnostics.adapter_event_received(self, event)`,记录一次事件已收到。必须位于调用监听器之前,避免路由、插件或模型失败改变接收事实。
- `pkg/telemetry/adapter_diagnostics.py`:识别适配器 API 最外层调用、平台专用 API 名、聊天与媒体类型。通过内部上下文标记抑制嵌套 API 重复计数。
- 转换失败无法可靠确定事件种类时,保留未知事件失败;转换成功本身不重复计算事件接收。
新增适配器或原生事件入口时,应检查事件是否经过统一分发入口。只在转换器上添加埋点不足以覆盖直接构造事件及交互回调。
## 数据约束
`adapter_evidence` 标记可用于验收的边界记录;`listener_registered` 标记注册事实;`chat_type``content_type` 仅允许有限类别。消息正文、API 参数、用户/群 ID、异常正文、媒体地址不进入这些字段。内部嵌套调用标记不得序列化。
平台专用动作优先匹配清单,名称为 `platform_api.<动作名>`;标准交互 API 保持其标准名称。未知动作不能借通用 `call_platform_api` 包装方法被计为已验收功能。清单读取显式使用 UTF-8,兼容 Windows 默认编码环境。
## 联动 Space
适配器清单或诊断操作变更后,同步生成 Space 的 `internal/diagnostics/catalog_v1.json`,并运行其目录一致性测试。Space 从清单生成完整矩阵,旧诊断记录不作为新增验收证据。部署时应先更新 Space 接收端与数据库迁移,再更新 Core Beta。
Core 回归命令:
```sh
uv run pytest tests/unit_tests/telemetry -q
```
重点测试在 `test_adapter_acceptance.py`:版本与开关、隐私、嵌套计数、专用/交互 API、分发入口覆盖、监听器失败。Space 仓库的 `docs/beta-adapter-acceptance.md` 说明验收界面、统计口径及联调方式。
@@ -174,6 +174,7 @@ class AiocqhttpAdapter(AiocqhttpAPIMixin, abstract_platform_adapter.AbstractPlat
await self._dispatch_eba_event(eba_event)
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -293,6 +293,7 @@ class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatfo
await self.logger.error(f'Error in dingtalk native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -390,6 +390,7 @@ class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatform
await self.logger.error(f'Error in discord {kind}: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -202,6 +202,7 @@ class KookAdapter(KookAPIMixin, BasePlatformAdapter):
await self.logger.error(f'Error handling KOOK event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -765,6 +765,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
await self.logger.error(f'Error in lark message event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.Event):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -192,6 +192,7 @@ class OfficialAccountAdapter(OfficialAccountAPIMixin, abstract_platform_adapter.
await self.logger.error(f'Error in officialaccount native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -388,6 +388,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPl
await self.logger.error(f'Error in qqofficial native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -183,6 +183,7 @@ class SlackAdapter(SlackAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
await self.logger.error(f'Error in slack native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -482,6 +482,7 @@ class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatfo
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
"""Dispatch once, preferring the most specific registered listener."""
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -387,7 +387,13 @@ class LegacyEventConverter(abstract_platform_adapter.AbstractEventConverter):
return event.source_platform_object
@staticmethod
@diagnostics.observe('event', 'platform.target2yiri', source='platform', stage='convert')
@diagnostics.observe(
'event',
'platform.target2yiri',
source='platform',
stage='convert',
fields=lambda _: {'attributes': {'adapter_evidence': False}},
)
async def target2yiri(event: Update, bot: telegram.Bot, bot_account_id: str):
"""Convert to legacy format (FriendMessage / GroupMessage)."""
import langbot_plugin.api.entities.builtin.platform.events as legacy_events
@@ -198,6 +198,7 @@ class WecomAdapter(WecomAPIMixin, abstract_platform_adapter.AbstractPlatformAdap
await self.logger.error(f'Error in wecom native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -312,6 +312,7 @@ class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatfo
await self.logger.error(f'Error in wecombot feedback event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
@@ -214,6 +214,7 @@ class WecomCSAdapter(WecomCSAPIMixin, abstract_platform_adapter.AbstractPlatform
await self.logger.error(f'Error in wecomcs native event: {traceback.format_exc()}')
async def _dispatch_eba_event(self, event: platform_events.EBAEvent):
diagnostics.adapter_event_received(self, event)
for event_type in (type(event), platform_events.EBAEvent, platform_events.Event):
callback = self.listeners.get(event_type)
if callback:
+2 -1
View File
@@ -1549,7 +1549,6 @@ class RuntimeBot:
from ..telemetry.diagnostic_catalog import snapshot_bot
snapshot_bot(self)
get_supported_events = getattr(self.adapter, 'get_supported_events', None)
supported_events: list[str] = []
if callable(get_supported_events):
@@ -1621,6 +1620,8 @@ class RuntimeBot:
platform_events.EBAEvent,
tenant_scoped_listener(on_eba_event),
)
# Registration is evidence of an installed listener, not a live connection.
snapshot_bot(self, listener_registered=True)
async def run(self):
async def exception_wrapper():
@@ -0,0 +1,70 @@
"""Finite adapter acceptance evidence, without message contents or target IDs."""
from __future__ import annotations
from .diagnostic_catalog import catalog
def boundary_fields(module, kind, operation, bound, parent):
"""Only the actual adapter API / EBA conversion boundary counts as a test."""
entry = next((v for k, v in catalog().items() if k in module.split('.')), None)
if entry is None or '.platform.adapters.' not in module:
return {}
evidence = kind == 'event' and operation == 'platform.target2yiri'
resolved = operation
if kind == 'api':
evidence = True
if operation == 'call_platform_api':
action = bound.get('action')
if isinstance(action, str) and 'platform_api.' + action in entry['specific_apis']:
resolved = 'platform_api.' + action
elif action in entry['apis'] and action != 'call_platform_api':
resolved = action
else:
# Keep the generic boundary for investigation, not a false named test.
evidence = False
# A forwarding API may call another decorated method. Count the outer call.
if parent and parent.adapter_api_active:
evidence = False
if not evidence:
return {'_adapter_api_active': kind == 'api'}
return {
'_adapter_api_active': kind == 'api',
'operation': resolved,
'attributes': {'adapter_evidence': True, **message_scenario(bound)},
}
def message_scenario(bound):
"""Read typed routing/media categories only; never serialize a payload."""
from langbot_plugin.api.entities.builtin.platform.events import MessageReceivedEvent
from langbot_plugin.api.entities.builtin.platform.message import MessageChain
event = bound.get('event') or bound.get('message_source')
target = bound.get('target_type') or bound.get('chat_type')
message = bound.get('message') or bound.get('new_content')
if isinstance(event, MessageReceivedEvent):
target = event.chat_type
message = event.message_chain
result = {}
if target is not None:
target = getattr(target, 'value', target)
if target == 'private':
target = 'person'
result['chat_type'] = target if target in ('person', 'group') else 'unknown'
if isinstance(message, MessageChain):
types = set()
for item in message:
name = type(item).__name__
types.add(
{
'Plain': 'text',
'Image': 'image',
'Voice': 'audio',
'Audio': 'audio',
'Video': 'video',
'File': 'file',
}.get(name, 'other')
)
result['content_type'] = next(iter(types)) if len(types) == 1 else 'mixed' if types else 'unknown'
return result
@@ -15,7 +15,7 @@ def catalog():
result = {}
base = Path(__file__).resolve().parents[1] / 'platform'
for manifest in sorted((base / 'adapters').glob('*/manifest.yaml')):
data = yaml.safe_load(manifest.read_text())
data = yaml.safe_load(manifest.read_text(encoding='utf-8'))
spec = data.get('spec', {})
name = data['metadata']['name']
privacy.code_value('adapter', name)
@@ -26,9 +26,16 @@ def catalog():
privacy.code_value('platform_event_type', event)
for operation in apis:
privacy.code_value('operation', operation)
specific_apis = []
for api in spec.get('platform_specific_apis', []):
privacy.code_value('operation', api['action'])
result[manifest.parent.name] = {'adapter': name, 'events': events, 'apis': apis}
specific_apis.append(privacy.code_value('operation', 'platform_api.' + api['action']))
result[manifest.parent.name] = {
'adapter': name,
'events': events,
'apis': apis,
'specific_apis': specific_apis,
}
return result
@@ -38,7 +45,7 @@ def adapter_fields(adapter):
return {'adapter': entry['adapter']} if entry else {}
def _snapshot_bot(bot):
def _snapshot_bot(bot, *, listener_registered=False):
from . import diagnostics
manager = getattr(bot.ap, 'diagnostics', None)
@@ -51,13 +58,15 @@ def _snapshot_bot(bot):
entry = next(e for e in catalog().values() if e['adapter'] == fields['adapter'])
for capability_type, method, declared in (
('event', 'get_supported_events', entry['events']),
('api', 'get_supported_apis', entry['apis']),
('api', 'get_supported_apis', entry['apis'] + entry['specific_apis']),
):
try:
supported = set(getattr(bot.adapter, method)() or [])
except Exception:
supported = set()
for name in declared:
if name == 'call_platform_api':
continue
manager.emit(
'capability',
name if capability_type == 'api' else 'platform.receive',
@@ -69,15 +78,16 @@ def _snapshot_bot(bot):
attributes={
'capability_type': capability_type,
'capability_name': name,
'supported': name in supported,
'supported': name in supported
or (name in entry['specific_apis'] and 'call_platform_api' in supported),
'configured': True,
'available': True,
**({'listener_registered': listener_registered} if capability_type == 'event' else {}),
},
)
def snapshot_bot(bot):
def snapshot_bot(bot, *, listener_registered=False):
try:
_snapshot_bot(bot)
_snapshot_bot(bot, listener_registered=listener_registered)
except Exception:
pass
@@ -32,8 +32,12 @@ VOCABULARY: dict[str, set[str]] = {
'arch': {'x86_64', 'aarch64', 'arm64', 'amd64'},
'database': {'sqlite', 'postgresql'},
'edition': {'community', 'cloud', 'enterprise'},
'chat_type': {'person', 'group', 'unknown'},
'content_type': {'text', 'image', 'audio', 'video', 'file', 'mixed', 'other', 'unknown'},
}
BOOLS = frozenset('stream synthetic configured available previous_session_unclean recovered supported'.split())
BOOLS = frozenset(
'stream synthetic configured available previous_session_unclean recovered supported adapter_evidence listener_registered'.split()
)
NUMBERS = frozenset(
'attempts successes failures cancellations timeouts partial unknown generated queued acked dropped retried failed queue_size capacity result_count input_tokens output_tokens'.split()
)
+74
View File
@@ -34,6 +34,43 @@ def annotate(**fields):
span.fields.update(fields)
def adapter_event_received(owner, event):
"""Record one converted event before dispatch, including native callback paths."""
with contextlib.suppress(Exception):
manager = _manager(owner)
if manager is None:
return
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
from .adapter_diagnostics import message_scenario
if not isinstance(event, EBAEvent) or not _context_matches(manager, owner, _owner_context(owner)):
return
fields = _context_fields(owner, {'event': event})
fields['attributes'] = {
**fields.get('attributes', {}),
'adapter_evidence': True,
**message_scenario({'event': event}),
}
parent = current_span()
if (
parent
and parent.manager is manager
and not (
fields.get('workspace_uuid')
and parent.fields.get('workspace_uuid')
and fields['workspace_uuid'] != parent.fields['workspace_uuid']
)
):
fields['trace_id'] = parent.fields['trace_id']
fields['parent_span_id'] = parent.fields['span_id']
if parent.fields.get('source') in ('webui_debug', 'synthetic'):
fields['source'] = parent.fields['source']
fields['attributes']['synthetic'] = True
fields.setdefault('source', 'platform')
privacy.code_value('operation', 'platform.adapter_event')
manager.emit('event', 'platform.adapter_event', 'succeeded', stage='accepted', **fields)
def _owner_app(owner):
"""An explicit owner (even absent/disabled) is an inheritance barrier."""
if owner is None:
@@ -148,6 +185,7 @@ class Span:
self.kind = kind
self.operation = operation
self.fields = dict(fields)
self.adapter_api_active = bool(self.fields.pop('_adapter_api_active', False))
if (
parent
and parent.fields.get('workspace_uuid')
@@ -167,6 +205,7 @@ class Span:
)
self.fields['span_id'] = str(uuid4())
if parent and parent.manager is manager:
self.adapter_api_active = self.adapter_api_active or parent.adapter_api_active
self.fields['parent_span_id'] = parent.fields['span_id']
for key in ('workspace_uuid', 'adapter', 'processor_type', 'platform_event_type', 'run_id'):
if not self.fields.get(key) and parent.fields.get(key):
@@ -213,8 +252,25 @@ def result_outcome(value):
if span is not None and span.fields.get('stage') == 'convert':
if isinstance(value, EBAEvent):
annotate(platform_event_type=value.type)
if span.fields.get('attributes', {}).get('adapter_evidence'):
from .adapter_diagnostics import message_scenario
annotate(attributes={**span.fields['attributes'], **message_scenario({'event': value})})
# Successful conversion is counted once at dispatch, which also
# covers adapters constructing EBA events in native callbacks.
span.fields['attributes']['adapter_evidence'] = False
elif value is None:
set_outcome('skipped', reason_code='not_matched')
if not isinstance(value, EBAEvent) and span.fields.get('attributes', {}).get('adapter_evidence'):
span.fields['attributes']['adapter_evidence'] = False
if span is not None and span.kind == 'api' and span.fields.get('attributes', {}).get('adapter_evidence'):
# Common adapter response contracts expose status without inspecting content.
if isinstance(value, dict) and (
value.get('ok') is False
or value.get('status') == 'failed'
or (type(value.get('retcode')) is int and value['retcode'] != 0)
):
set_outcome('failed', reason_code='response_error')
if isinstance(value, ActionResponse):
if value.code != 0:
set_outcome('failed', reason_code='response_error')
@@ -261,6 +317,24 @@ def observe(kind, operation, *, source='internal', stage='execute', ap=None, fie
extra = fields(bound)
extra['attributes'] = {**metadata.get('attributes', {}), **extra.get('attributes', {})}
metadata.update(extra)
from .adapter_diagnostics import boundary_fields
if parent and (
parent.manager is not manager
or (
metadata.get('workspace_uuid')
and parent.fields.get('workspace_uuid')
and metadata['workspace_uuid'] != parent.fields['workspace_uuid']
)
):
parent = None
evidence = (
boundary_fields(fn.__module__, kind, operation, bound, parent)
if metadata.get('attributes', {}).get('adapter_evidence') is not False
else {}
)
metadata['attributes'] = {**metadata.get('attributes', {}), **evidence.pop('attributes', {})}
metadata.update(evidence)
return Span(manager, kind, operation, metadata)
except Exception:
return None
@@ -0,0 +1,208 @@
"""Adapter acceptance boundaries and strict Beta-only production."""
import json
from types import SimpleNamespace
from uuid import uuid4
import pytest
from langbot_plugin.api.entities.builtin.platform.events import MessageReceivedEvent
from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain, Image
from langbot.pkg.telemetry import diagnostics as d
from langbot.pkg.telemetry import adapter_diagnostics as adapter
from langbot.pkg.telemetry.diagnostic_catalog import snapshot_bot, catalog
def make_manager(version='4.11.0b2', **config):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid', **config}}))
ap.diagnostics = d.DiagnosticsManager(ap, version=version, instance_id='instance-test', capacity=2048)
return ap.diagnostics
def boundary(fn, operation='send_message', kind='api'):
fn.__module__ = 'langbot.pkg.platform.adapters.telegram.adapter'
return d.observe(kind, operation, source='platform', stage='convert' if kind == 'event' else 'accepted')(fn)
def evidence(m):
return [e for e in m.pending if e['attributes'].get('adapter_evidence') and e['outcome'] != 'started']
@pytest.mark.asyncio
@pytest.mark.parametrize(
'version,config',
[
('4.11.0', {}),
('4.11.0a1', {}),
('4.11.0rc1', {}),
('4.11.0.dev1', {}),
('4.11.0b2+local', {}),
('invalid', {}),
('4.11.0b2', {'disable_telemetry': True}),
('4.11.0b2', {'disable_beta_diagnostics': True}),
],
)
async def test_disabled_adapter_produces_nothing(version, config, monkeypatch):
m = make_manager(version, **config)
def forbidden(*args, **kwargs):
raise AssertionError('disabled producer projected adapter metadata')
monkeypatch.setattr(adapter, 'boundary_fields', forbidden)
async def call(self):
return 42
assert await boundary(call)(SimpleNamespace(ap=m.ap)) == 42
snapshot_bot(SimpleNamespace(ap=m.ap), listener_registered=True)
d.adapter_event_received(SimpleNamespace(ap=m.ap), MessageReceivedEvent())
assert not m.pending
assert m.counters['generated'] == 0
await m.flush_once()
assert m.counters['acked'] == 0
@pytest.mark.asyncio
async def test_nested_api_counted_once_and_only_finite_scenario():
m = make_manager()
owner = SimpleNamespace(ap=m.ap)
async def leaf(self, message, target_type):
return {'ok': True, 'private_result': 'SECRET_CANARY'}
third = boundary(leaf)
async def middle(self, message, target_type):
return await third(self, message, target_type)
second = boundary(middle)
async def outer(self, message, target_type):
return await second(self, message, target_type)
await boundary(outer)(
owner, MessageChain([Plain(text='SECRET_CANARY'), Image(url='https://secret.invalid')]), 'person'
)
rows = evidence(m)
assert len(rows) == 1
assert rows[0]['attributes'] == {'adapter_evidence': True, 'chat_type': 'person', 'content_type': 'mixed'}
assert len(m.pending) == 6
assert 'SECRET_CANARY' not in json.dumps(list(m.pending))
assert '_adapter_api_active' not in json.dumps(list(m.pending))
@pytest.mark.asyncio
async def test_api_specific_interaction_unknown_and_failure():
m = make_manager()
async def call(self, action, params):
return {'ok': False, 'description': 'SECRET_CANARY'}
call = boundary(call, 'call_platform_api')
actions = catalog()['telegram']['specific_apis']
assert actions
action = actions[0].removeprefix('platform_api.')
for name in (action, 'interaction.request', 'SECRET_CANARY'):
await call(SimpleNamespace(ap=m.ap), name, {'token': 'SECRET_CANARY'})
rows = evidence(m)
assert [r['operation'] for r in rows] == [actions[0], 'interaction.request']
assert all(r['outcome'] == 'failed' for r in rows)
assert 'SECRET_CANARY' not in json.dumps(list(m.pending))
@pytest.mark.asyncio
async def test_conversion_count_and_private_message_scenario():
m = make_manager()
async def convert(self, event):
return event
call = boundary(convert, 'platform.target2yiri', 'event')
event = await call(
SimpleNamespace(ap=m.ap), MessageReceivedEvent(message_chain=MessageChain([Plain(text='SECRET_CANARY')]))
)
assert not evidence(m)
d.adapter_event_received(SimpleNamespace(ap=m.ap), event)
await call(SimpleNamespace(ap=m.ap), None)
await call(SimpleNamespace(ap=m.ap), {'legacy': 'SECRET_CANARY'})
rows = evidence(m)
assert len(rows) == 1
assert rows[0]['platform_event_type'] == 'message.received'
assert rows[0]['attributes']['chat_type'] == 'person'
assert rows[0]['attributes']['content_type'] == 'text'
assert 'SECRET_CANARY' not in json.dumps(list(m.pending))
def test_registered_snapshot_includes_specific_apis_without_claiming_connection():
m = make_manager()
entry = catalog()['telegram']
cls = type(
'Adapter',
(),
{
'__module__': 'langbot.pkg.platform.adapters.telegram.adapter',
'get_supported_events': lambda self: entry['events'],
'get_supported_apis': lambda self: entry['apis'],
},
)
snapshot_bot(
SimpleNamespace(ap=m.ap, adapter=cls(), execution_context=SimpleNamespace(workspace_uuid=str(uuid4()))),
listener_registered=True,
)
rows = list(m.pending)
assert rows
assert all('available' not in r['attributes'] for r in rows)
assert all(r['attributes']['listener_registered'] for r in rows if r['attributes']['capability_type'] == 'event')
names = {r['attributes']['capability_name'] for r in rows}
assert set(entry['specific_apis']) <= names
@pytest.mark.asyncio
async def test_real_dispatch_success_precedes_listener_failure():
from unittest.mock import AsyncMock
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
from langbot_plugin.api.entities.builtin.platform.events import EBAEvent
m = make_manager()
owner = SimpleNamespace(ap=m.ap, listeners={EBAEvent: AsyncMock(side_effect=ValueError('SECRET_CANARY'))})
with pytest.raises(ValueError):
await TelegramAdapter._dispatch_eba_event(owner, MessageReceivedEvent())
rows = evidence(m)
assert len(rows) == 1 and rows[0]['outcome'] == 'succeeded'
assert rows[0]['operation'] == 'platform.adapter_event'
def test_event_dispatch_does_not_borrow_another_workspace_trace():
from langbot.pkg.api.http.context import ExecutionContext
m = make_manager()
workspace = str(uuid4())
owner = SimpleNamespace(
ap=m.ap,
execution_context=ExecutionContext(
instance_uuid=m.instance_id, workspace_uuid=workspace, placement_generation=1
),
)
with d.Span(m, 'event', 'platform.receive', {'workspace_uuid': str(uuid4()), 'source': 'webui_debug'}).activate():
d.adapter_event_received(owner, MessageReceivedEvent())
row = evidence(m)[0]
assert row['workspace_uuid'] == workspace
assert not row.get('parent_span_id')
assert row['source'] == 'platform'
assert not row['attributes'].get('synthetic')
def test_every_adapter_records_native_and_interaction_dispatch():
import ast
from pathlib import Path
base = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/platform/adapters'
for directory in catalog():
tree = ast.parse((base / directory / 'adapter.py').read_text(encoding='utf-8'))
dispatch = next(
n for n in ast.walk(tree) if isinstance(n, ast.AsyncFunctionDef) and n.name == '_dispatch_eba_event'
)
assert any(
isinstance(n, ast.Call) and ast.unparse(n.func) == 'diagnostics.adapter_event_received'
for n in ast.walk(dispatch)
), directory