fix(telemetry): require acknowledged adapter evidence and prepare beta.3

This commit is contained in:
dadachann
2026-09-15 13:23:12 +00:00
parent f7cfb23480
commit 03854b5d33
9 changed files with 599 additions and 25 deletions
@@ -0,0 +1,52 @@
"""Exercise release tag selection without building or pushing an image."""
import os
from pathlib import Path
import subprocess
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[3]
def _build_args(tmp_path, step_name, version):
workflow = yaml.safe_load((ROOT / '.github/workflows/build-docker-image.yml').read_text())
steps = workflow['jobs']['publish-docker-image']['steps']
step = next(step for step in steps if step['name'] == step_name)
command = step['run'].replace('${{ github.sha }}', 'a' * 40)
command = command.replace('${{ steps.check_version.outputs.version }}', version)
capture = tmp_path / 'docker-args'
script = 'docker() { printf "%s\\n" "$@" > "$CAPTURE"; };\n' + command
if 'env' in step:
assert step['env']['RELEASE_VERSION'] == '${{ steps.check_version.outputs.version }}'
subprocess.run(
['bash', '-euo', 'pipefail', '-c', script],
check=True,
env={**os.environ, 'CAPTURE': str(capture), 'RELEASE_VERSION': version},
)
args = capture.read_text().splitlines()
assert '--push' in args
assert args[args.index('--platform') + 1] == 'linux/arm64,linux/amd64'
assert args[args.index('--build-arg') + 1] == 'LANGBOT_BUILD_REVISION=' + 'a' * 40
return [args[i + 1] for i, value in enumerate(args) if value == '-t'], step['if']
@pytest.mark.parametrize('version', ['v4.11.0-beta.3', 'v4.12.0-beta.1'])
def test_beta_release_updates_beta_but_not_latest(tmp_path, version):
tags, condition = _build_args(tmp_path, 'Build for Pre-release', version)
assert tags == [f'rockchin/langbot:{version}', 'rockchin/langbot:beta']
assert condition == '${{ github.event.release.prerelease == true }}'
@pytest.mark.parametrize('version', ['v4.11.0-rc.1', 'v4.12.0-alpha.1'])
def test_other_prereleases_do_not_update_channels(tmp_path, version):
tags, _ = _build_args(tmp_path, 'Build for Pre-release', version)
assert tags == [f'rockchin/langbot:{version}']
def test_stable_release_keeps_latest_without_updating_beta(tmp_path):
tags, condition = _build_args(tmp_path, 'Build for Release', 'v4.11.0')
assert tags == ['rockchin/langbot:v4.11.0', 'rockchin/langbot:latest']
assert condition == '${{ github.event.release.prerelease == false }}'
@@ -0,0 +1,318 @@
"""Real adapter acceptance must reflect confirmed operations, not normal returns."""
import base64
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import httpx
import pytest
from langbot_plugin.api.entities.builtin.platform.message import File, MessageChain, Plain
from langbot.pkg.api.http.context import ExecutionContext
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
from langbot.pkg.telemetry import diagnostics as d
def make_adapter(version='4.11.0b3', **config):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid', **config}}))
manager = d.DiagnosticsManager(ap, version=version, instance_id='instance-test')
ap.diagnostics = manager
context = ExecutionContext(instance_uuid=manager.instance_id, workspace_uuid=str(uuid4()), placement_generation=1)
sdk = SimpleNamespace(edit_message_text=AsyncMock(return_value=True), send_message=AsyncMock(return_value=True))
adapter = TelegramAdapter.model_construct(
bot=sdk, config={'markdown_card': False}, logger=SimpleNamespace(ap=ap, execution_context=context), listeners={}
)
return adapter, manager, sdk
async def wire_events(manager):
batches = []
async def sender(request):
batch = json.loads(request.content)
batches.append(batch)
return httpx.Response(
200,
json={
'code': 200,
'data': {'accepted_event_ids': [e['event_id'] for e in batch['events']], 'rejected': []},
},
)
manager.client = httpx.AsyncClient(transport=httpx.MockTransport(sender))
await manager.flush_once()
assert not manager.pending
await manager.shutdown(drain_timeout=0)
assert 'PRIVATE_' not in json.dumps(batches)
return [event for batch in batches for event in batch['events']]
def successes(events):
return [e for e in events if e['attributes'].get('adapter_evidence') and e['outcome'] == 'succeeded']
@pytest.mark.asyncio
async def test_real_telegram_file_only_edit_is_not_acceptance_through_sender():
adapter, manager, sdk = make_adapter()
for _ in range(2):
result = await adapter.edit_message(
'person',
'PRIVATE_CHAT',
'PRIVATE_MESSAGE',
MessageChain([File(name='PRIVATE_FILE', base64=base64.b64encode(b'PRIVATE_BYTES').decode())]),
)
assert result is None
sdk.edit_message_text.assert_not_awaited()
events = await wire_events(manager)
assert len([e for e in events if e['operation'] == 'edit_message']) == 4
assert not successes(events)
@pytest.mark.asyncio
async def test_real_telegram_acknowledged_void_edit_is_acceptance_through_sender():
adapter, manager, sdk = make_adapter()
assert (
await adapter.edit_message(
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
)
is None
)
sdk.edit_message_text.assert_awaited_once_with(
chat_id='PRIVATE_CHAT', message_id='PRIVATE_MESSAGE', text='PRIVATE_TEXT'
)
events = await wire_events(manager)
assert len(successes(events)) == 1
assert successes(events)[0]['attributes']['content_type'] == 'text'
@pytest.mark.asyncio
async def test_real_telegram_empty_send_is_not_acceptance():
adapter, manager, sdk = make_adapter()
assert await adapter.send_message('person', 'PRIVATE_CHAT', MessageChain([])) is None
sdk.send_message.assert_not_awaited()
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_telegram_edit_exception_is_unchanged():
adapter, manager, sdk = make_adapter()
error = ValueError('PRIVATE_ERROR')
sdk.edit_message_text.side_effect = error
with pytest.raises(ValueError) as raised:
await adapter.edit_message(
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
)
assert raised.value is error
events = await wire_events(manager)
assert not successes(events)
assert any(e['outcome'] == 'failed' and e['operation'] == 'edit_message' for e in events)
@pytest.mark.asyncio
@pytest.mark.parametrize(
'version,config',
[('4.11.0', {}), ('4.11.0b3', {'disable_telemetry': True}), ('4.11.0b3', {'disable_beta_diagnostics': True})],
)
async def test_real_adapter_disabled_gates_keep_business_behavior(version, config):
adapter, manager, sdk = make_adapter(version, **config)
assert (
await adapter.edit_message(
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
)
is None
)
sdk.edit_message_text.assert_awaited_once()
assert await wire_events(manager) == []
@pytest.mark.asyncio
@pytest.mark.parametrize('result', [None, False, {}, {'queued': True}, {'stream': False}])
async def test_unconfirmed_normal_return_is_not_acceptance(result):
adapter, manager, _ = make_adapter()
async def call(self):
return result
call.__module__ = 'langbot.pkg.platform.adapters.telegram.adapter'
observed = d.observe('api', 'send_message', source='platform', stage='accepted')(call)
assert await observed(adapter) is result
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
@pytest.mark.parametrize('raw', [{}, {'results': []}, {'result': None}, {'queued': True}])
async def test_message_result_source_id_is_not_confirmation(raw):
from langbot_plugin.api.entities.builtin.platform.events import MessageResult
adapter, manager, _ = make_adapter()
result = MessageResult(message_id='PRIVATE_SOURCE_ID', raw=raw)
async def call(self):
return result
call.__module__ = 'langbot.pkg.platform.adapters.wecom.adapter'
observed = d.observe('api', 'reply_message', source='platform', stage='accepted')(call)
assert await observed(adapter) is result
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_discord_void_delete_is_confirmed():
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
adapter, manager, _ = make_adapter()
message = SimpleNamespace(delete=AsyncMock(return_value=None))
channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message))
discord = DiscordAdapter.model_construct(
bot=SimpleNamespace(get_channel=lambda _: channel), logger=adapter.logger, config={}, listeners={}
)
assert await discord.delete_message('group', '123', '456') is None
message.delete.assert_awaited_once()
assert len(successes(await wire_events(manager))) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize('result', [False, None])
async def test_real_telegram_unacknowledged_edit_keeps_void_return(result):
adapter, manager, sdk = make_adapter()
sdk.edit_message_text.return_value = result
assert (
await adapter.edit_message(
'person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', MessageChain([Plain(text='PRIVATE_TEXT')])
)
is None
)
sdk.edit_message_text.assert_awaited_once()
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_telegram_acknowledged_void_delete_is_acceptance():
adapter, manager, sdk = make_adapter()
sdk.delete_message = AsyncMock(return_value=True)
assert await adapter.delete_message('person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE') is None
sdk.delete_message.assert_awaited_once()
assert len(successes(await wire_events(manager))) == 1
@pytest.mark.asyncio
async def test_real_telegram_sdk_message_send_is_acceptance():
import datetime
import telegram
adapter, manager, sdk = make_adapter()
sdk.send_message.return_value = telegram.Message(
message_id=123, date=datetime.datetime.now(datetime.timezone.utc), chat=telegram.Chat(id=456, type='private')
)
assert await adapter.send_message('person', 'PRIVATE_CHAT', MessageChain([Plain(text='PRIVATE_TEXT')])) is None
sdk.send_message.assert_awaited_once()
assert len(successes(await wire_events(manager))) == 1
@pytest.mark.asyncio
@pytest.mark.parametrize(
'raw', [{'ok': True, 'raw': None}, {'ok': True, 'raw': {'errcode': 42}}, {'results': [{'ok': True}, None]}]
)
async def test_wrapped_ack_does_not_hide_missing_or_failed_response(raw):
adapter, manager, _ = make_adapter()
async def call(self):
return raw
call.__module__ = 'langbot.pkg.platform.adapters.wecombot.adapter'
observed = d.observe('api', 'reply_message', source='platform', stage='accepted')(call)
assert await observed(adapter) is raw
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
@pytest.mark.parametrize(
'action', ['check_tenant_access_token', 'refresh_app_access_token', 'refresh_tenant_access_token']
)
async def test_real_lark_self_app_token_noop_is_not_acceptance(action):
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
adapter, manager, _ = make_adapter()
lark = LarkAdapter.model_construct(config={'app_type': 'self'}, logger=adapter.logger, listeners={})
assert await lark.call_platform_api(action, {}) == {'ok': True}
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_telegram_mixed_edit_cannot_confirm_ignored_file():
adapter, manager, sdk = make_adapter()
message = MessageChain([Plain(text='PRIVATE_TEXT'), File(name='PRIVATE_FILE', base64='eA==')])
assert await adapter.edit_message('person', 'PRIVATE_CHAT', 'PRIVATE_MESSAGE', message) is None
sdk.edit_message_text.assert_awaited_once()
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_discord_sent_message_result_is_confirmed():
from langbot.pkg.platform.adapters.discord.adapter import DiscordAdapter
adapter, manager, _ = make_adapter()
channel = SimpleNamespace(send=AsyncMock(return_value=SimpleNamespace(id=123)))
discord = DiscordAdapter.model_construct(
bot=SimpleNamespace(get_channel=lambda _: channel), logger=adapter.logger, config={}, listeners={}
)
result = await discord.send_message('group', '456', MessageChain([Plain(text='PRIVATE_TEXT')]))
assert result.message_id == 123
channel.send.assert_awaited_once_with(content='PRIVATE_TEXT')
assert len(successes(await wire_events(manager))) == 1
@pytest.mark.asyncio
async def test_real_aiocqhttp_empty_forward_is_not_acceptance():
from langbot.pkg.platform.adapters.aiocqhttp.adapter import AiocqhttpAdapter
from langbot_plugin.api.entities.builtin.platform.message import Forward
adapter, manager, _ = make_adapter()
sdk = SimpleNamespace(call_action=AsyncMock())
onebot = AiocqhttpAdapter.model_construct(bot=sdk, logger=adapter.logger, config={}, listeners={})
result = await onebot.send_message('group', '123', MessageChain([Forward(node_list=[])]))
assert result.message_id is None and result.raw == {}
sdk.call_action.assert_not_awaited()
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_telegram_unsupported_exception_is_unchanged():
from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError
adapter, manager, _ = make_adapter()
with pytest.raises(NotSupportedError):
await adapter.upload_file(b'PRIVATE_BYTES', 'PRIVATE_FILE')
assert not successes(await wire_events(manager))
@pytest.mark.asyncio
async def test_real_lark_sent_message_result_is_confirmed():
from langbot.pkg.platform.adapters.lark.adapter import LarkAdapter
adapter, manager, _ = make_adapter()
create = AsyncMock(
return_value=SimpleNamespace(success=lambda: True, data=SimpleNamespace(message_id='PRIVATE_SENT_ID'))
)
lark = LarkAdapter.model_construct(
config={'app_type': 'self'},
logger=adapter.logger,
listeners={},
api_client=SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(message=SimpleNamespace(acreate=create)))),
)
result = await lark.send_message('group', 'PRIVATE_CHAT', MessageChain([Plain(text='PRIVATE_TEXT')]))
assert result.message_id == 'PRIVATE_SENT_ID'
create.assert_awaited_once()
assert len(successes(await wire_events(manager))) == 1
@pytest.mark.asyncio
async def test_real_telegram_returned_user_info_is_confirmed():
adapter, manager, sdk = make_adapter()
sdk.get_chat = AsyncMock(return_value=SimpleNamespace(id=123, first_name='PRIVATE_NAME', username='PRIVATE_USER'))
result = await adapter.get_user_info('PRIVATE_USER')
assert result.id == 123
sdk.get_chat.assert_awaited_once_with(chat_id='PRIVATE_USER')
assert len(successes(await wire_events(manager))) == 1