mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
feat(telemetry): add isolated beta quality diagnostics and release identity
This commit is contained in:
@@ -1473,3 +1473,56 @@ async def test_synthetic_event_query_exposes_trusted_workspace_to_tools(clean_ag
|
||||
app.skill_mgr.get_skills = lambda scope: received.append(scope) or {}
|
||||
get_visible_skills(app, synthetic)
|
||||
assert received[0].workspace_uuid == context.workspace_uuid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_diagnostics_close_releases_real_orchestrator_session(clean_agent_state):
|
||||
from langbot.pkg.telemetry.diagnostics import DiagnosticsManager
|
||||
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[{'type': 'message.completed', 'data': {'message': {'role': 'assistant', 'content': 'CANARY'}}}]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, clean_agent_state)
|
||||
ap.instance_config = types.SimpleNamespace(data={'space': {'url': 'https://example.invalid'}})
|
||||
ap.diagnostics = DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(make_descriptor()))
|
||||
gen = orchestrator.run_from_query(make_query())
|
||||
assert (await anext(gen)).content == 'CANARY'
|
||||
run_id = plugin_connector.contexts[0]['run_id']
|
||||
assert await get_session_registry().get(run_id) is not None
|
||||
await gen.aclose()
|
||||
assert await get_session_registry().get(run_id) is None
|
||||
records = [e for e in ap.diagnostics.pending if e['operation'] == 'runner.run']
|
||||
assert records[-1]['outcome'] == 'cancelled'
|
||||
import json
|
||||
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('terminal', ['run.completed', 'run.failed'])
|
||||
async def test_beta_diagnostics_real_runner_terminal(clean_agent_state, terminal):
|
||||
from langbot.pkg.telemetry.diagnostics import DiagnosticsManager
|
||||
|
||||
plugin_connector = FakePluginConnector(
|
||||
results=[
|
||||
{
|
||||
'type': terminal,
|
||||
'data': {'finish_reason': 'stop'} if terminal == 'run.completed' else {'error': 'CANARY'},
|
||||
}
|
||||
]
|
||||
)
|
||||
ap = FakeApplication(plugin_connector, clean_agent_state)
|
||||
ap.instance_config = types.SimpleNamespace(data={'space': {'url': 'https://example.invalid'}})
|
||||
ap.diagnostics = DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||
orchestrator = AgentRunOrchestrator(ap, FakeRegistry(make_descriptor()))
|
||||
try:
|
||||
_ = [v async for v in orchestrator.run_from_query(make_query())]
|
||||
except RunnerExecutionError:
|
||||
assert terminal == 'run.failed'
|
||||
records = [e for e in ap.diagnostics.pending if e['operation'] == 'runner.run']
|
||||
assert records[-1]['outcome'] == ('succeeded' if terminal == 'run.completed' else 'failed')
|
||||
assert records[-1]['run_id'] == plugin_connector.contexts[0]['run_id']
|
||||
import json
|
||||
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Real Quart registration boundaries with content-canary payloads."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.group import AuthType, RouterGroup
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
|
||||
|
||||
class Recorder:
|
||||
enabled = True
|
||||
|
||||
def __init__(self):
|
||||
self.events = []
|
||||
|
||||
def emit(self, kind, operation, outcome, **fields):
|
||||
# A real transport projects the exception class, never its message.
|
||||
error = fields.pop('error', None)
|
||||
if error is not None:
|
||||
fields['error_type'] = type(error).__name__
|
||||
self.events.append(dict(kind=kind, operation=operation, outcome=outcome, **fields))
|
||||
|
||||
|
||||
class Routes(RouterGroup):
|
||||
path = '/management'
|
||||
name = 'management'
|
||||
|
||||
async def initialize(self):
|
||||
@self.route('/ok/<identifier>', auth_type=AuthType.NONE)
|
||||
async def success(identifier):
|
||||
self.ap.seen.append(d.current_span())
|
||||
return self.success({'secret': identifier})
|
||||
|
||||
@self.route('/business', auth_type=AuthType.NONE)
|
||||
async def business():
|
||||
return self.fail('private-error-code', 'private-error-message')
|
||||
|
||||
@self.route('/auth')
|
||||
async def authenticated():
|
||||
raise AssertionError('must not run')
|
||||
|
||||
@self.route('/error', auth_type=AuthType.NONE)
|
||||
async def error():
|
||||
raise ValueError('private-exception-message')
|
||||
|
||||
@self.route('/cancel', auth_type=AuthType.NONE)
|
||||
async def cancel():
|
||||
raise asyncio.CancelledError('private-cancel-message')
|
||||
|
||||
@self.route('/stream', auth_type=AuthType.NONE)
|
||||
async def stream():
|
||||
async def body():
|
||||
self.ap.seen.append(d.current_span())
|
||||
yield b'private-stream-chunk'
|
||||
self.ap.seen.append(d.current_span())
|
||||
|
||||
return quart.Response(body())
|
||||
|
||||
|
||||
async def setup(manager=True):
|
||||
app = quart.Quart(__name__)
|
||||
ap = SimpleNamespace(seen=[])
|
||||
if manager is True:
|
||||
manager = Recorder()
|
||||
if manager is not None:
|
||||
ap.diagnostics = manager
|
||||
routes = Routes(ap, app)
|
||||
await routes.initialize()
|
||||
return app, ap, routes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_success_uses_code_identity_not_path_or_payload():
|
||||
app, ap, _ = await setup()
|
||||
response = await app.test_client().get(
|
||||
'/management/ok/private-id?token=private-query', headers={'Authorization': 'private-token'}
|
||||
)
|
||||
assert (await response.get_json())['data']['secret'] == 'private-id'
|
||||
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', 'succeeded']
|
||||
event = ap.diagnostics.events[-1]
|
||||
assert event['source'] == 'http'
|
||||
assert re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', event['operation'])
|
||||
assert ap.seen[0] is not None
|
||||
assert d.current_span() is None
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'path,status,outcome', [('business', 200, 'failed'), ('auth', 401, 'rejected'), ('error', 500, 'failed')]
|
||||
)
|
||||
async def test_http_business_auth_and_exception_outcomes(path, status, outcome):
|
||||
app, ap, _ = await setup()
|
||||
response = await app.test_client().get('/management/' + path)
|
||||
assert response.status_code == status
|
||||
assert ap.diagnostics.events[-1]['outcome'] == outcome
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_cancellation_propagates():
|
||||
app, ap, _ = await setup()
|
||||
async with app.test_request_context('/management/cancel'):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await app.full_dispatch_request()
|
||||
assert ap.diagnostics.events[-1]['outcome'] == 'cancelled'
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_auth_cancellation_not_reinterpreted_as_api_key():
|
||||
app, ap, routes = await setup()
|
||||
routes._authenticate_support_admin = AsyncMock(return_value=None)
|
||||
routes._authenticate_account = AsyncMock(side_effect=asyncio.CancelledError())
|
||||
async with app.test_request_context('/management/auth', headers={'Authorization': 'Bearer private-token'}):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await app.full_dispatch_request()
|
||||
assert ap.diagnostics.events[-1]['outcome'] == 'cancelled'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_stream_has_parent_context_without_consumer_leak():
|
||||
app, ap, _ = await setup()
|
||||
async with app.test_request_context('/management/stream'):
|
||||
response = await app.full_dispatch_request()
|
||||
assert [e['outcome'] for e in ap.diagnostics.events] == ['started']
|
||||
assert d.current_span() is None
|
||||
async with response.response as body:
|
||||
iterator = body.__aiter__()
|
||||
assert await anext(iterator) == b'private-stream-chunk'
|
||||
assert d.current_span() is None
|
||||
assert ap.seen[-1] is not None
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(iterator)
|
||||
assert ap.seen[0] is ap.seen[1]
|
||||
assert ap.diagnostics.events[-1]['outcome'] == 'succeeded'
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'manager', [None, SimpleNamespace(enabled=False, emit=lambda *a, **kw: pytest.fail('disabled emission'))]
|
||||
)
|
||||
async def test_absent_disabled_manager_preserves_result_without_context(manager):
|
||||
app, ap, _ = await setup(manager)
|
||||
response = await app.test_client().get('/management/ok/private-id')
|
||||
assert response.status_code == 200
|
||||
assert ap.seen == [None]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broken_diagnostic_emit_never_masks_operation():
|
||||
class Broken(Recorder):
|
||||
def emit(self, *args, **kwargs):
|
||||
raise RuntimeError('diagnostics broken')
|
||||
|
||||
app, _, _ = await setup(Broken())
|
||||
response = await app.test_client().get('/management/ok/private-id')
|
||||
assert response.status_code == 200
|
||||
async with app.test_request_context('/management/cancel'):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await app.full_dispatch_request()
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anonymous_handlers_have_distinct_stable_code_operations():
|
||||
app, ap, routes = await setup()
|
||||
|
||||
@routes.route('/items/<identifier>', auth_type=AuthType.NONE, methods=['GET'])
|
||||
async def _(identifier):
|
||||
return routes.success()
|
||||
|
||||
@routes.route('/items/<identifier>', auth_type=AuthType.NONE, methods=['POST'])
|
||||
async def _(identifier):
|
||||
return routes.success()
|
||||
|
||||
@routes.route('/other', auth_type=AuthType.NONE)
|
||||
async def _():
|
||||
return routes.success()
|
||||
|
||||
await app.test_client().get('/management/items/private-id')
|
||||
await app.test_client().post('/management/items/private-id')
|
||||
await app.test_client().get('/management/other')
|
||||
operations = [e['operation'] for e in ap.diagnostics.events if e['outcome'] == 'started']
|
||||
assert len(set(operations)) == 3
|
||||
assert all(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', op) for op in operations)
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Verify identities across every source-declared management registration."""
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langbot.pkg.api.management_diagnostics import operation_id
|
||||
|
||||
|
||||
def test_every_http_registration_has_a_distinct_wire_safe_operation():
|
||||
root = Path(__file__).resolve().parents[3] / 'src/langbot/pkg/api/http/controller/groups'
|
||||
identities = []
|
||||
for path in root.rglob('*.py'):
|
||||
module = 'langbot.pkg.api.http.controller.groups.' + '.'.join(path.relative_to(root).with_suffix('').parts)
|
||||
for cls in ast.walk(ast.parse(path.read_text())):
|
||||
if not isinstance(cls, ast.ClassDef):
|
||||
continue
|
||||
prefix = ''
|
||||
for decorator in cls.decorator_list:
|
||||
if (
|
||||
isinstance(decorator, ast.Call)
|
||||
and isinstance(decorator.func, ast.Attribute)
|
||||
and decorator.func.attr == 'group_class'
|
||||
):
|
||||
prefix = ast.literal_eval(decorator.args[1])
|
||||
for fn in ast.walk(cls):
|
||||
if not isinstance(fn, ast.AsyncFunctionDef):
|
||||
continue
|
||||
for decorator in fn.decorator_list:
|
||||
if not (
|
||||
isinstance(decorator, ast.Call)
|
||||
and isinstance(decorator.func, ast.Attribute)
|
||||
and decorator.func.attr == 'route'
|
||||
):
|
||||
continue
|
||||
rule = prefix + ast.literal_eval(decorator.args[0])
|
||||
methods = next(
|
||||
(ast.literal_eval(k.value) for k in decorator.keywords if k.arg == 'methods'), ['GET']
|
||||
)
|
||||
operation = operation_id(
|
||||
'http', SimpleNamespace(__module__=module, __name__=fn.name), rule=rule, methods=methods
|
||||
)
|
||||
assert re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', operation), operation
|
||||
identities.append(operation)
|
||||
assert len(identities) >= 200 # Guard against accidentally scanning an empty/subset tree.
|
||||
assert len(set(identities)) == len(identities)
|
||||
@@ -0,0 +1,431 @@
|
||||
"""CORE-DIAG-6: management ownership barriers under a foreign live span."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api import management_diagnostics as md
|
||||
from langbot.pkg.api.http.context import (
|
||||
ExecutionContext,
|
||||
PrincipalContext,
|
||||
PrincipalType,
|
||||
RequestContext,
|
||||
WorkspaceContext,
|
||||
)
|
||||
from langbot.pkg.api.http.controller.group import AuthType
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.embed import EmbedRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
||||
from langbot.pkg.api.http.service.agent import AgentService
|
||||
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
from tests.unit_tests.api.test_diagnostics_management_http import Recorder, Routes
|
||||
|
||||
|
||||
MODES = ['enabled', 'disabled', 'absent', 'mismatched', 'policy_off', 'beta_off', 'broken']
|
||||
|
||||
|
||||
def app(identity):
|
||||
ap = NS(instance_config=NS(data={'space': {'url': 'https://example.invalid'}}))
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id=identity)
|
||||
return ap
|
||||
|
||||
|
||||
def owners(mode):
|
||||
a, b = app('instance-A'), app('instance-B')
|
||||
b_manager = b.diagnostics
|
||||
if mode == 'disabled':
|
||||
b.instance_config.data['space']['disable_telemetry'] = True
|
||||
b.diagnostics = d.DiagnosticsManager(b, version='4.11.0b2', instance_id='instance-B')
|
||||
b_manager = b.diagnostics
|
||||
elif mode == 'absent':
|
||||
del b.diagnostics
|
||||
elif mode == 'mismatched':
|
||||
b.diagnostics = a.diagnostics
|
||||
elif mode in {'policy_off', 'beta_off'}:
|
||||
# Also test an attached structural producer whose enabled flag stays true.
|
||||
b.diagnostics = Recorder()
|
||||
b.instance_config.data['space']['disable_telemetry' if mode == 'policy_off' else 'disable_beta_diagnostics'] = (
|
||||
True
|
||||
)
|
||||
elif mode == 'broken':
|
||||
b.diagnostics = NS(enabled=True, emit=lambda *a, **kw: (_ for _ in ()).throw(RuntimeError('broken')))
|
||||
ca = ExecutionContext('instance-A', str(uuid4()), 1)
|
||||
cb = ExecutionContext('instance-B', str(uuid4()), 1)
|
||||
d.privacy.code_value('operation', 'http.isolation.parent')
|
||||
parent = d.Span(a.diagnostics, 'api', 'http.isolation.parent', {'workspace_uuid': ca.workspace_uuid})
|
||||
return a, b, b_manager, ca, cb, parent
|
||||
|
||||
|
||||
@d.observe('event', 'platform.target2yiri', source='platform', stage='convert')
|
||||
async def converter(native):
|
||||
return native
|
||||
|
||||
|
||||
async def nested(b, cb, seen):
|
||||
md.workspace(cb)
|
||||
seen.append(d.current_span())
|
||||
with md.scope(b, 'websocket.isolation.inner', source='websocket'):
|
||||
md.workspace(cb)
|
||||
assert await converter(42) == 42
|
||||
# Both an inner management boundary and an ownerless converter must be safe.
|
||||
assert await converter(43) == 43
|
||||
return 44
|
||||
|
||||
|
||||
def assert_isolated(a, b, b_manager, ca, cb, parent, mode):
|
||||
assert parent.fields['workspace_uuid'] == ca.workspace_uuid
|
||||
assert parent.outcome is None
|
||||
assert len(a.diagnostics.pending) == 1 # Only the caller's own start event.
|
||||
assert not b_manager.pending if mode != 'enabled' else b_manager.pending
|
||||
if isinstance(getattr(b, 'diagnostics', None), Recorder):
|
||||
assert b.diagnostics.events == []
|
||||
if mode == 'enabled':
|
||||
assert all(e['instance_id'] == 'instance-B' for e in b_manager.pending)
|
||||
assert all(e['trace_id'] != parent.fields['trace_id'] for e in b_manager.pending)
|
||||
assert all(e['workspace_uuid'] != ca.workspace_uuid for e in b_manager.pending)
|
||||
assert any(e['workspace_uuid'] == cb.workspace_uuid for e in b_manager.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
@pytest.mark.parametrize('shape', ['decorator', 'scope'])
|
||||
async def test_nested_management_masks_foreign_parent_and_restores_caller(mode, shape):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
seen = []
|
||||
|
||||
@md.observe('http.isolation.endpoint', source='http', ap=b)
|
||||
async def endpoint():
|
||||
return await nested(b, cb, seen)
|
||||
|
||||
with parent.activate():
|
||||
if shape == 'decorator':
|
||||
assert await endpoint() == 44
|
||||
else:
|
||||
with md.scope(b, 'websocket.isolation.outer', source='websocket'):
|
||||
assert await nested(b, cb, seen) == 44
|
||||
assert d.current_span() is parent
|
||||
assert d.current_span() is None
|
||||
assert (seen[0] is not None) == (mode == 'enabled')
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
async def test_actual_agent_debug_never_mutates_foreign_workspace(mode):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
service = AgentService(b)
|
||||
seen = []
|
||||
|
||||
async def missing(*args):
|
||||
await nested(b, cb, seen)
|
||||
return None
|
||||
|
||||
service.get_agent = missing
|
||||
with parent.activate():
|
||||
with pytest.raises(ValueError, match='^Agent not found$'):
|
||||
await service.debug_agent(cb, 'private-agent', {})
|
||||
assert d.current_span() is parent
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
@pytest.mark.parametrize('termination', ['exhaust', 'close', 'error', 'cancel'])
|
||||
async def test_real_http_body_keeps_barrier_during_advancement_and_cleanup(mode, termination):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
web = quart.Quart(__name__)
|
||||
routes = Routes(b, web)
|
||||
seen, closed = [], []
|
||||
failure = asyncio.CancelledError('private-cancel') if termination == 'cancel' else ValueError('private-error')
|
||||
|
||||
@routes.route('/isolated', auth_type=AuthType.NONE)
|
||||
async def endpoint():
|
||||
await nested(b, cb, seen)
|
||||
|
||||
async def stream():
|
||||
try:
|
||||
await nested(b, cb, seen)
|
||||
yield b'one'
|
||||
if termination in {'cancel', 'error'}:
|
||||
raise failure
|
||||
await nested(b, cb, seen)
|
||||
finally:
|
||||
await nested(b, cb, seen)
|
||||
closed.append(True)
|
||||
|
||||
return quart.Response(stream())
|
||||
|
||||
# Construct with no caller parent; the later body consumer has app A's span.
|
||||
async with web.test_request_context('/management/isolated'):
|
||||
response = await web.full_dispatch_request()
|
||||
with parent.activate():
|
||||
try:
|
||||
async with response.response as body:
|
||||
iterator = body.__aiter__()
|
||||
assert await anext(iterator) == b'one'
|
||||
assert d.current_span() is parent
|
||||
if termination != 'close':
|
||||
with pytest.raises((StopAsyncIteration, type(failure))) as caught:
|
||||
await anext(iterator)
|
||||
if termination in {'cancel', 'error'}:
|
||||
assert caught.value is failure
|
||||
else:
|
||||
assert isinstance(caught.value, StopAsyncIteration)
|
||||
finally:
|
||||
assert d.current_span() is parent
|
||||
assert closed == [True]
|
||||
assert all((span is not None) == (mode == 'enabled') for span in seen)
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
async def test_real_mcp_registered_tool_masks_foreign_parent(mode):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
seen = []
|
||||
|
||||
async def get_bot(*args, **kwargs):
|
||||
return {'value': await nested(b, cb, seen)}
|
||||
|
||||
b.bot_service = NS(get_bot=get_bot)
|
||||
server = LangBotMCPServer(b)
|
||||
with parent.activate(), patch('langbot.pkg.api.mcp.server._authorized', return_value=cb):
|
||||
assert await server.mcp.call_tool('get_bot', {'bot_uuid': 'private-bot'})
|
||||
assert d.current_span() is parent
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||
async def test_real_websocket_receive_masks_foreign_parent(mode, group_class):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
group = group_class(b, quart.Quart(__name__))
|
||||
connection = NS(is_active=True, connection_id='private-id', send_queue=asyncio.Queue())
|
||||
seen = []
|
||||
|
||||
async def handle(*args, **kwargs):
|
||||
await nested(b, cb, seen)
|
||||
|
||||
adapter = NS(handle_websocket_message=handle)
|
||||
if group_class is WebSocketChatRouterGroup:
|
||||
group._revalidate_websocket_authorization = AsyncMock(return_value=cb)
|
||||
else:
|
||||
group._resolve_connected_bot = AsyncMock(return_value=NS(execution_context=cb))
|
||||
|
||||
async def receive():
|
||||
connection.is_active = False
|
||||
return '{"type":"message","text":"private-prompt"}'
|
||||
|
||||
with (
|
||||
parent.activate(),
|
||||
patch('quart.websocket', NS(receive=receive)),
|
||||
patch(
|
||||
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat.ws_connection_manager.update_activity',
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await group._handle_receive(connection, adapter, NS(execution_context=cb), 'private-token')
|
||||
assert d.current_span() is parent
|
||||
assert seen
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', ['execution', 'request'])
|
||||
def test_workspace_rejects_foreign_instance_or_foreign_active_span(kind):
|
||||
a, b, _, ca, cb, parent = owners('enabled')
|
||||
context = cb
|
||||
if kind == 'request':
|
||||
context = RequestContext(
|
||||
cb.instance_uuid,
|
||||
1,
|
||||
'request',
|
||||
'api_key',
|
||||
PrincipalContext(PrincipalType.API_KEY),
|
||||
WorkspaceContext(cb.workspace_uuid, None, None, frozenset()),
|
||||
)
|
||||
with md.scope(a, 'http.isolation.annotation', source='http'):
|
||||
span = d.current_span()
|
||||
md.workspace(context)
|
||||
assert 'workspace_uuid' not in span.fields
|
||||
md.workspace(ca)
|
||||
assert span.fields['workspace_uuid'] == ca.workspace_uuid
|
||||
with parent.activate():
|
||||
before = dict(parent.fields)
|
||||
md.workspace(ca)
|
||||
md.workspace(context)
|
||||
assert parent.fields == before
|
||||
|
||||
|
||||
def test_structural_recorder_workspace_and_scope_exception_identity():
|
||||
recorder = Recorder()
|
||||
ap = NS(diagnostics=recorder)
|
||||
ctx = ExecutionContext('instance', str(uuid4()), 1)
|
||||
error = ValueError('private-error')
|
||||
with pytest.raises(ValueError) as caught:
|
||||
with md.scope(ap, 'http.isolation.recorder', source='http'):
|
||||
md.workspace(ctx)
|
||||
assert d.current_span().fields['workspace_uuid'] == ctx.workspace_uuid
|
||||
raise error
|
||||
assert caught.value is error
|
||||
assert recorder.events[-1]['outcome'] == 'failed'
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES[1:])
|
||||
@pytest.mark.parametrize('termination', ['success', 'error', 'cancel'])
|
||||
async def test_nested_off_boundary_restores_outer_annotation_and_error(mode, termination):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
error = asyncio.CancelledError('private-cancel') if termination == 'cancel' else ValueError('private-error')
|
||||
|
||||
@md.observe('http.isolation.off', source='http', ap=b)
|
||||
async def endpoint():
|
||||
await nested(b, cb, [])
|
||||
md.outcome('failed')
|
||||
if termination != 'success':
|
||||
raise error
|
||||
return 42
|
||||
|
||||
with parent.activate():
|
||||
with md.scope(a, 'http.isolation.outer', source='http'):
|
||||
outer = d.current_span()
|
||||
if termination == 'success':
|
||||
assert await endpoint() == 42
|
||||
else:
|
||||
with pytest.raises(type(error)) as caught:
|
||||
await endpoint()
|
||||
assert caught.value is error
|
||||
assert d.current_span() is outer
|
||||
md.workspace(ca)
|
||||
assert outer.fields['workspace_uuid'] == ca.workspace_uuid
|
||||
assert outer.outcome is None
|
||||
assert d.current_span() is parent
|
||||
assert not manager.pending
|
||||
assert [e['operation'] for e in a.diagnostics.pending] == [
|
||||
'http.isolation.parent',
|
||||
'http.isolation.outer',
|
||||
'http.isolation.outer',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||
async def test_websocket_send_cancel_restores_foreign_parent(mode, group_class):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
group = group_class(b, quart.Quart(__name__))
|
||||
connection = NS(is_active=False, send_queue=asyncio.Queue())
|
||||
await connection.send_queue.put({'text': 'private-answer'})
|
||||
error = asyncio.CancelledError('private-cancel')
|
||||
|
||||
async def send(payload):
|
||||
await nested(b, cb, [])
|
||||
raise error
|
||||
|
||||
with parent.activate(), patch('quart.websocket', NS(send=send)):
|
||||
with pytest.raises(asyncio.CancelledError) as caught:
|
||||
await group._handle_send(connection)
|
||||
assert caught.value is error
|
||||
assert d.current_span() is parent
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', MODES)
|
||||
async def test_sync_http_body_exhaustion_has_owned_context_without_consumer_leak(mode):
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
seen, closed = [], []
|
||||
|
||||
def stream():
|
||||
try:
|
||||
for value in (b'one', b'two'):
|
||||
md.workspace(cb)
|
||||
seen.append(d.current_span())
|
||||
yield value
|
||||
finally:
|
||||
seen.append(d.current_span())
|
||||
closed.append(True)
|
||||
|
||||
@md.observe('http.isolation.sync_body', source='http', ap=b, http=True)
|
||||
async def endpoint():
|
||||
return quart.Response(stream())
|
||||
|
||||
response = await endpoint()
|
||||
with parent.activate():
|
||||
async with response.response as body:
|
||||
chunks = []
|
||||
async for chunk in body:
|
||||
assert d.current_span() is parent
|
||||
chunks.append(chunk)
|
||||
assert d.current_span() is parent
|
||||
assert chunks == [b'one', b'two']
|
||||
assert closed == [True]
|
||||
assert all((span is not None) == (mode == 'enabled') for span in seen)
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('mode', ['enabled', 'disabled', 'absent', 'mismatched'])
|
||||
@pytest.mark.parametrize('failure_stage', ['enter', 'iterator', 'advance', 'exit', None])
|
||||
async def test_custom_http_body_protocol_and_cancellation_identity(mode, failure_stage):
|
||||
from quart.wrappers.response import IterableBody
|
||||
|
||||
a, b, manager, ca, cb, parent = owners(mode)
|
||||
error = asyncio.CancelledError('private-body-cancel')
|
||||
seen = []
|
||||
|
||||
def visit(stage):
|
||||
md.workspace(cb)
|
||||
seen.append((stage, d.current_span()))
|
||||
if stage == failure_stage:
|
||||
raise error
|
||||
|
||||
class Body(IterableBody):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
visit('enter')
|
||||
return self
|
||||
|
||||
def __aiter__(self):
|
||||
visit('iterator')
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
visit('advance')
|
||||
raise StopAsyncIteration
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
visit('exit')
|
||||
|
||||
@md.observe('http.isolation.body_protocol', source='http', ap=b, http=True)
|
||||
async def endpoint():
|
||||
response = quart.Response()
|
||||
response.response = Body()
|
||||
return response
|
||||
|
||||
response = await endpoint()
|
||||
|
||||
async def consume():
|
||||
async with response.response as body:
|
||||
assert d.current_span() is parent
|
||||
async for _ in body:
|
||||
pytest.fail('empty body yielded')
|
||||
|
||||
with parent.activate():
|
||||
if failure_stage is None:
|
||||
await consume()
|
||||
else:
|
||||
with pytest.raises(asyncio.CancelledError) as caught:
|
||||
await consume()
|
||||
assert caught.value is error
|
||||
assert d.current_span() is parent
|
||||
assert all((span is not None) == (mode == 'enabled') for _, span in seen)
|
||||
assert [s for s, _ in seen].count('exit') == (0 if failure_stage in {'enter', 'iterator'} else 1)
|
||||
assert_isolated(a, b, manager, ca, cb, parent, mode)
|
||||
@@ -0,0 +1,163 @@
|
||||
"""HTTP streaming termination, fail-open behavior and real producer privacy."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.agent.runner import errors as runner_errors
|
||||
from langbot.pkg.api import management_diagnostics as md
|
||||
from langbot.pkg.api.http.controller.group import AuthType
|
||||
from langbot.pkg.api.http.controller.groups.agent_debug_stream import debug_stream_response
|
||||
from langbot.pkg.api.mcp.mount import MCPMount
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
from tests.unit_tests.api.test_diagnostics_management_http import Recorder, setup
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('termination', ['close', 'cancel', 'error'])
|
||||
async def test_stream_termination_preserves_cleanup_and_error(termination):
|
||||
app, ap, routes = await setup()
|
||||
closed = []
|
||||
|
||||
@routes.route('/end', auth_type=AuthType.NONE)
|
||||
async def end():
|
||||
async def body():
|
||||
try:
|
||||
yield b'one'
|
||||
if termination == 'error':
|
||||
raise ValueError('private-error')
|
||||
if termination == 'cancel':
|
||||
raise asyncio.CancelledError('private-cancel')
|
||||
yield b'two'
|
||||
finally:
|
||||
closed.append(d.current_span())
|
||||
|
||||
return quart.Response(body())
|
||||
|
||||
async with app.test_request_context('/management/end'):
|
||||
response = await app.full_dispatch_request()
|
||||
if termination == 'close':
|
||||
async with response.response as body:
|
||||
assert await anext(body.__aiter__()) == b'one'
|
||||
else:
|
||||
error = ValueError if termination == 'error' else asyncio.CancelledError
|
||||
with pytest.raises(error):
|
||||
async with response.response as body:
|
||||
iterator = body.__aiter__()
|
||||
assert await anext(iterator) == b'one'
|
||||
await anext(iterator)
|
||||
assert len(closed) == 1 and closed[0] is not None
|
||||
expected = 'failed' if termination == 'error' else 'cancelled'
|
||||
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', expected]
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_ndjson_error_marks_http_business_failure():
|
||||
app, ap, routes = await setup()
|
||||
service = SimpleNamespace(debug_agent=AsyncMock(side_effect=runner_errors.RunnerNotFoundError('private-error')))
|
||||
|
||||
@routes.route('/ndjson', auth_type=AuthType.NONE)
|
||||
async def ndjson():
|
||||
return debug_stream_response(service, object(), 'private-agent', {'text': 'private-text'})
|
||||
|
||||
response = await app.test_client().get('/management/ndjson')
|
||||
assert response.status_code == 200
|
||||
assert json.loads(await response.get_data())['kind'] == 'error'
|
||||
assert ap.diagnostics.events[-1]['outcome'] == 'failed'
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_terminal_diagnostic_failure_preserves_success_and_stream():
|
||||
class BrokenFinish(Recorder):
|
||||
def emit(self, kind, operation, outcome, **fields):
|
||||
if outcome != 'started':
|
||||
raise RuntimeError('private-diagnostic-error')
|
||||
super().emit(kind, operation, outcome, **fields)
|
||||
|
||||
app, _, _ = await setup(BrokenFinish())
|
||||
response = await app.test_client().get('/management/stream')
|
||||
assert await response.get_data() == b'private-stream-chunk'
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_manager_status_privacy_and_no_network_in_request():
|
||||
app, ap, _ = await setup(None)
|
||||
ap.instance_config = SimpleNamespace(data={'space': {'url': 'https://example.invalid'}})
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0-beta.2', instance_id='instance-test', capacity=20)
|
||||
ap.diagnostics.credentials = AsyncMock(side_effect=AssertionError('must not await credentials'))
|
||||
for path in ['ok/private-identifier', 'business', 'auth', 'cancel']:
|
||||
async with app.test_request_context('/management/' + path):
|
||||
try:
|
||||
await app.full_dispatch_request()
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
events = list(ap.diagnostics.pending)
|
||||
assert len(events) == 8
|
||||
assert {e['outcome'] for e in events} == {'started', 'succeeded', 'failed', 'rejected', 'cancelled'}
|
||||
assert all(re.fullmatch(r'[A-Za-z_][A-Za-z0-9_.:-]{0,127}', e['operation']) for e in events)
|
||||
assert 'private-' not in json.dumps(events)
|
||||
ap.diagnostics.credentials.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'config,version',
|
||||
[
|
||||
({}, '4.11.0'),
|
||||
({'disable_telemetry': True}, '4.11.0-beta.2'),
|
||||
({'disable_beta_diagnostics': True}, '4.11.0-beta.2'),
|
||||
],
|
||||
)
|
||||
async def test_real_manager_disabled_modes_do_not_install_context(config, version):
|
||||
app, ap, _ = await setup(None)
|
||||
ap.instance_config = SimpleNamespace(data={'space': config})
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version=version, instance_id='instance-test')
|
||||
response = await app.test_client().get('/management/ok/private-id')
|
||||
assert response.status_code == 200
|
||||
assert ap.seen == [None]
|
||||
assert not ap.diagnostics.pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_mount_observes_auth_without_headers_or_body():
|
||||
ap = SimpleNamespace(
|
||||
diagnostics=Recorder(), apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None))
|
||||
)
|
||||
mount = MCPMount(ap)
|
||||
send = AsyncMock()
|
||||
fallback = AsyncMock()
|
||||
await mount.wrap(fallback)(
|
||||
{'type': 'http', 'path': '/mcp/private-path', 'headers': [(b'x-api-key', b'private-token')]}, AsyncMock(), send
|
||||
)
|
||||
assert send.call_args_list[0].args[0]['status'] == 401
|
||||
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', 'rejected']
|
||||
assert ap.diagnostics.events[-1]['operation'] == 'mcp.request'
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
fallback.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_boundary_source_inherits_without_duplicate_run():
|
||||
recorder = Recorder()
|
||||
ap = SimpleNamespace(diagnostics=recorder)
|
||||
|
||||
@md.observe('http.test.debug', source='webui_debug', ap=ap)
|
||||
async def debug():
|
||||
@d.observe('run', 'runner.run', source='agent', ap=ap)
|
||||
async def run():
|
||||
return 'private-content'
|
||||
|
||||
return await run()
|
||||
|
||||
assert await debug() == 'private-content'
|
||||
assert len([e for e in recorder.events if e['kind'] == 'run' and e['outcome'] == 'started']) == 1
|
||||
assert all(e['source'] == 'webui_debug' and e['attributes']['synthetic'] for e in recorder.events)
|
||||
assert 'private-' not in json.dumps(recorder.events)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Management MCP, WebSocket and debug execution boundaries."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.websocket_chat import WebSocketChatRouterGroup
|
||||
from langbot.pkg.api.http.controller.groups.pipelines.embed import EmbedRouterGroup
|
||||
from langbot.pkg.api.mcp.server import LangBotMCPServer
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
from tests.unit_tests.api.test_diagnostics_management_http import Recorder
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_real_registered_tool_has_source_and_never_content():
|
||||
recorder = Recorder()
|
||||
seen = []
|
||||
|
||||
async def get_bot(*args, **kwargs):
|
||||
seen.append(d.current_span())
|
||||
return {'secret': 'private-result'}
|
||||
|
||||
ap = SimpleNamespace(diagnostics=recorder, bot_service=SimpleNamespace(get_bot=get_bot))
|
||||
server = LangBotMCPServer(ap)
|
||||
with patch('langbot.pkg.api.mcp.server._authorized', return_value=object()):
|
||||
result = await server.mcp.call_tool('get_bot', {'bot_uuid': 'private-bot'})
|
||||
assert result
|
||||
assert [e['outcome'] for e in recorder.events] == ['started', 'succeeded']
|
||||
assert recorder.events[-1]['operation'] == 'mcp.get_bot'
|
||||
assert recorder.events[-1]['source'] == 'mcp'
|
||||
assert seen[0] is not None
|
||||
assert 'private-' not in json.dumps(recorder.events)
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_permission_rejection_is_observed():
|
||||
server = LangBotMCPServer(SimpleNamespace(diagnostics=Recorder()))
|
||||
with pytest.raises(Exception):
|
||||
await server.mcp.call_tool('list_bots', {})
|
||||
assert server.ap.diagnostics.events[-1]['outcome'] in {'rejected', 'failed'}
|
||||
assert server.ap.diagnostics.events[-1]['source'] == 'mcp'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'group_class,path',
|
||||
[
|
||||
(WebSocketChatRouterGroup, '/api/v1/pipelines/private-pipeline/ws/connect'),
|
||||
(EmbedRouterGroup, '/api/v1/embed/11111111-1111-4111-8111-111111111111/ws/connect?session_id=private-session'),
|
||||
],
|
||||
)
|
||||
async def test_real_websocket_session_auth_rejection_is_content_free(group_class, path):
|
||||
app = quart.Quart(__name__)
|
||||
ap = SimpleNamespace(diagnostics=Recorder())
|
||||
group = group_class(ap, app)
|
||||
group._authenticate_websocket = AsyncMock(side_effect=ValueError('private-token'))
|
||||
if group_class is EmbedRouterGroup:
|
||||
group._resolve_bot = AsyncMock(return_value=(object(), 'private-pipeline'))
|
||||
await group.initialize()
|
||||
async with app.test_client().websocket(path) as socket:
|
||||
frame = json.loads(await socket.receive())
|
||||
assert frame['type'] == 'error'
|
||||
terminal = [e for e in ap.diagnostics.events if e['outcome'] != 'started']
|
||||
assert terminal
|
||||
assert terminal[-1]['outcome'] == 'rejected'
|
||||
assert terminal[-1]['source'] == 'websocket'
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||
async def test_websocket_received_message_boundaries_do_not_capture_frames(group_class):
|
||||
app = quart.Quart(__name__)
|
||||
ap = SimpleNamespace(diagnostics=Recorder())
|
||||
group = group_class(ap, app)
|
||||
connection = SimpleNamespace(is_active=True, connection_id='private-id', send_queue=asyncio.Queue())
|
||||
adapter = SimpleNamespace(handle_websocket_message=AsyncMock())
|
||||
if group_class is WebSocketChatRouterGroup:
|
||||
group._revalidate_websocket_authorization = AsyncMock(return_value=object())
|
||||
args = (connection, adapter, object(), 'private-token')
|
||||
else:
|
||||
group._resolve_connected_bot = AsyncMock(return_value=object())
|
||||
args = (connection, adapter, object(), 'private-pipeline')
|
||||
|
||||
async def receive():
|
||||
connection.is_active = False
|
||||
return json.dumps({'type': 'message', 'text': 'private-prompt'})
|
||||
|
||||
with (
|
||||
patch('quart.websocket', SimpleNamespace(receive=receive)),
|
||||
patch(
|
||||
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat.ws_connection_manager.update_activity',
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await group._handle_receive(*args)
|
||||
adapter.handle_websocket_message.assert_awaited_once()
|
||||
assert [e['outcome'] for e in ap.diagnostics.events] == ['started', 'succeeded']
|
||||
assert ap.diagnostics.events[-1]['operation'].endswith('.message')
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_debug_service_marks_synthetic_source_before_validation():
|
||||
from langbot.pkg.api.http.service.agent import AgentService
|
||||
|
||||
ap = SimpleNamespace(diagnostics=Recorder())
|
||||
service = AgentService(ap)
|
||||
seen = []
|
||||
|
||||
async def get_agent(*args):
|
||||
seen.append(d.current_span())
|
||||
return None
|
||||
|
||||
service.get_agent = get_agent
|
||||
with pytest.raises(ValueError):
|
||||
await service.debug_agent(object(), 'private-id', {'text': 'private-prompt'})
|
||||
assert seen[0].fields['source'] == 'webui_debug'
|
||||
assert ap.diagnostics.events[-1]['operation'] == 'http.agent.debug_agent'
|
||||
assert ap.diagnostics.events[-1]['source'] == 'webui_debug'
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||
@pytest.mark.parametrize(
|
||||
'frame,expected', [('private-invalid-json', 'rejected'), ('{"type":"private-unknown"}', 'skipped')]
|
||||
)
|
||||
async def test_websocket_invalid_frames_have_finite_outcomes(group_class, frame, expected):
|
||||
app = quart.Quart(__name__)
|
||||
ap = SimpleNamespace(diagnostics=Recorder())
|
||||
group = group_class(ap, app)
|
||||
connection = SimpleNamespace(is_active=True, connection_id='private-id', send_queue=asyncio.Queue())
|
||||
|
||||
async def receive():
|
||||
connection.is_active = False
|
||||
return frame
|
||||
|
||||
with (
|
||||
patch('quart.websocket', SimpleNamespace(receive=receive)),
|
||||
patch(
|
||||
'langbot.pkg.api.http.controller.groups.pipelines.websocket_chat.ws_connection_manager.update_activity',
|
||||
new=AsyncMock(),
|
||||
),
|
||||
):
|
||||
await group._handle_receive(connection, object(), object(), 'private-token')
|
||||
assert ap.diagnostics.events[-1]['outcome'] == expected
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('group_class', [WebSocketChatRouterGroup, EmbedRouterGroup])
|
||||
async def test_websocket_send_boundary_preserves_payload_and_cancellation(group_class):
|
||||
app = quart.Quart(__name__)
|
||||
ap = SimpleNamespace(diagnostics=Recorder())
|
||||
group = group_class(ap, app)
|
||||
connection = SimpleNamespace(is_active=False, send_queue=asyncio.Queue())
|
||||
await connection.send_queue.put({'text': 'private-answer'})
|
||||
send = AsyncMock(side_effect=asyncio.CancelledError('private-error'))
|
||||
with patch('quart.websocket', SimpleNamespace(send=send)):
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await group._handle_send(connection)
|
||||
assert json.loads(send.call_args.args[0]) == {'text': 'private-answer'}
|
||||
assert ap.diagnostics.events[-1]['outcome'] == 'cancelled'
|
||||
assert ap.diagnostics.events[-1]['operation'].endswith('.send')
|
||||
assert 'private-' not in json.dumps(ap.diagnostics.events)
|
||||
assert d.current_span() is None
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Execute real Core boundaries with content canaries and early failures."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.agent.runner.orchestrator import AgentRunOrchestrator
|
||||
from langbot.pkg.agent.runner.reply_stream import ReplyStreamSession, ReplyStreamRequest
|
||||
|
||||
|
||||
def make_ap():
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={'space': {'url': 'https://example.invalid'}}))
|
||||
ap.persistence_mgr = SimpleNamespace(get_db_engine=lambda: None)
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id='instance-test')
|
||||
return ap
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('processor', ['pipeline', 'agent', 'event_processor'])
|
||||
async def test_real_orchestrator_prepare_failure(processor):
|
||||
ap = make_ap()
|
||||
registry = SimpleNamespace(get=AsyncMock(side_effect=ValueError('CANARY private runner URL')))
|
||||
orchestrator = AgentRunOrchestrator(ap, registry)
|
||||
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
event = SimpleNamespace(workspace_id=context.workspace_uuid, event_type='message.received')
|
||||
binding = SimpleNamespace(runner_id='CANARY', processor_type=processor)
|
||||
with pytest.raises(ValueError, match='CANARY'):
|
||||
await anext(orchestrator.run(event, binding, adapter_context={'_execution_context': context}))
|
||||
records = ap.diagnostics.pending
|
||||
assert [r['outcome'] for r in records] == ['started', 'failed']
|
||||
assert records[-1]['stage'] == 'prepare'
|
||||
assert records[-1]['workspace_uuid'] == context.workspace_uuid
|
||||
assert records[-1]['processor_type'] == processor
|
||||
assert 'CANARY' not in json.dumps(records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_reply_stream_mock_is_not_platform_success():
|
||||
ap = make_ap()
|
||||
event = SimpleNamespace(
|
||||
delivery=SimpleNamespace(reply_target={}, surface='webui', platform_capabilities={'debug_mock': True})
|
||||
)
|
||||
session = ReplyStreamSession(event)
|
||||
# Real runtime supplies the manager at construction from the orchestrator.
|
||||
session.diagnostics = ap.diagnostics
|
||||
result = await session.apply(ReplyStreamRequest(stream_id=uuid4(), operation='finish', text='CANARY user reply'))
|
||||
assert result['mock'] is True and result['text'] == 'CANARY user reply'
|
||||
assert ap.diagnostics.pending[-1]['source'] == 'webui_debug'
|
||||
assert ap.diagnostics.pending[-1]['attributes']['synthetic'] is True
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_bot_route_projects_status_not_reason():
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
ap = make_ap()
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.ap = ap
|
||||
bot.logger = SimpleNamespace(info=AsyncMock())
|
||||
await bot._record_event_route_trace(
|
||||
event_type='message.received',
|
||||
status='not_matched',
|
||||
text='CANARY secret',
|
||||
reason='CANARY',
|
||||
failure_code='route_not_found',
|
||||
)
|
||||
records = ap.diagnostics.pending
|
||||
assert records[-1]['outcome'] == 'skipped'
|
||||
assert records[-1]['reason_code'] == 'route_not_found'
|
||||
assert 'CANARY' not in json.dumps(records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_telegram_conversion_failure_before_bot_manager(monkeypatch):
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter
|
||||
|
||||
ap = make_ap()
|
||||
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
logger = EventLogger('test', ap, context, 'test')
|
||||
logger.error = AsyncMock()
|
||||
logger.warning = AsyncMock()
|
||||
adapter = TelegramAdapter({'token': '123456:ABCDEFGHIJKLMNOPQRSTUVWXYZ_123456789'}, logger)
|
||||
adapter.listeners = {}
|
||||
monkeypatch.setattr(
|
||||
TelegramEventConverter, '_convert_message', AsyncMock(side_effect=ValueError('CANARY conversion'))
|
||||
)
|
||||
update = SimpleNamespace(
|
||||
message=SimpleNamespace(from_user=SimpleNamespace(is_bot=False), text='CANARY text'),
|
||||
edited_message=None,
|
||||
chat_member=None,
|
||||
my_chat_member=None,
|
||||
callback_query=None,
|
||||
message_reaction=None,
|
||||
)
|
||||
callback = adapter.application.handlers[0][0].callback
|
||||
await callback(update, None)
|
||||
records = [e for e in ap.diagnostics.pending if e['stage'] == 'convert' and e['outcome'] == 'failed']
|
||||
assert records and records[-1]['adapter'] == 'telegram-omni'
|
||||
assert records[-1]['workspace_uuid'] == context.workspace_uuid
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_interaction_ack_skip_and_failure():
|
||||
from langbot.pkg.agent.runner.interaction_manager import InteractionManager
|
||||
|
||||
ap = make_ap()
|
||||
interactions = InteractionManager(ap, store=SimpleNamespace(record_delivery_success=AsyncMock()))
|
||||
await interactions.acknowledge_submission({}, SimpleNamespace(get_supported_apis=lambda: []))
|
||||
assert ap.diagnostics.pending[-1]['outcome'] == 'skipped'
|
||||
ap.diagnostics.pending.clear()
|
||||
ap.logger = SimpleNamespace(warning=lambda *a: None)
|
||||
adapter = SimpleNamespace(
|
||||
get_supported_apis=lambda: ['interaction.acknowledge'],
|
||||
call_platform_api=AsyncMock(side_effect=ValueError('CANARY ack')),
|
||||
)
|
||||
await interactions.acknowledge_submission({'delivery_result': {'secret': 'CANARY'}}, adapter)
|
||||
assert ap.diagnostics.pending[-1]['outcome'] == 'failed'
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
def test_actual_adapter_capability_snapshot():
|
||||
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
|
||||
from langbot.pkg.telemetry.diagnostic_catalog import snapshot_bot
|
||||
|
||||
ap = make_ap()
|
||||
context = ExecutionContext(instance_uuid='instance-test', workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
adapter = TelegramAdapter.model_construct(config={}, listeners={})
|
||||
snapshot_bot(SimpleNamespace(ap=ap, adapter=adapter, execution_context=context))
|
||||
records = ap.diagnostics.pending
|
||||
assert records and all(e['kind'] == 'capability' for e in records)
|
||||
api_rows = [e for e in records if e['attributes']['capability_type'] == 'api']
|
||||
assert any(e['operation'] == 'send_message' and e['attributes']['supported'] for e in api_rows)
|
||||
assert all(e['workspace_uuid'] == context.workspace_uuid for e in records)
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Independent-review regressions through real ownership and lifecycle boundaries."""
|
||||
|
||||
import asyncio
|
||||
import importlib.metadata
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.api.http.context import ExecutionContext
|
||||
from langbot.pkg.provider.modelmgr.requester import RuntimeProvider
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
|
||||
|
||||
def make_ap(identity='instance-test', disabled=False):
|
||||
ap = NS(instance_config=NS(data={'space': {'url': 'https://example.invalid', 'disable_telemetry': disabled}}))
|
||||
ap.diagnostics = d.DiagnosticsManager(ap, version='4.11.0b2', instance_id=identity)
|
||||
return ap
|
||||
|
||||
|
||||
def context(identity='instance-test'):
|
||||
return ExecutionContext(instance_uuid=identity, workspace_uuid=str(uuid4()), placement_generation=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [True, False])
|
||||
async def test_real_runtime_provider_cannot_use_other_app_manager(disabled):
|
||||
a, b = make_ap('instance-A'), make_ap('instance-B', disabled)
|
||||
ca, cb = context('instance-A'), context('instance-B')
|
||||
requester = NS(ap=b, invoke_llm=AsyncMock(return_value='business result'))
|
||||
provider = RuntimeProvider(cb, NS(workspace_uuid=cb.workspace_uuid), None, requester)
|
||||
model = NS(execution_context=cb, provider=provider)
|
||||
parent = d.Span(a.diagnostics, 'api', 'review.parent', {'workspace_uuid': ca.workspace_uuid})
|
||||
with parent.activate():
|
||||
assert await provider.invoke_llm(None, model, [], execution_context=cb) == 'business result'
|
||||
assert not [e for e in a.diagnostics.pending if e['operation'] == 'model.invoke_llm']
|
||||
assert len(b.diagnostics.pending) == (0 if disabled else 2)
|
||||
for event in b.diagnostics.pending:
|
||||
assert event['instance_id'] == 'instance-B'
|
||||
assert event['workspace_uuid'] == cb.workspace_uuid
|
||||
assert event['trace_id'] != parent.fields['trace_id']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('shape', ['app', 'ap', 'logger', 'requester', 'adapter', 'direct', 'absent'])
|
||||
async def test_explicit_disabled_owner_blocks_parent_even_in_ownerless_children(shape):
|
||||
a, b = make_ap('instance-A'), make_ap('instance-B', True)
|
||||
shapes = {
|
||||
'app': b,
|
||||
'ap': NS(ap=b),
|
||||
'logger': NS(logger=NS(ap=b)),
|
||||
'requester': NS(requester=NS(ap=b)),
|
||||
'adapter': NS(adapter=NS(logger=NS(ap=b))),
|
||||
'direct': NS(diagnostics=b.diagnostics),
|
||||
'absent': NS(ap=None),
|
||||
}
|
||||
|
||||
@d.observe('api', 'review.child')
|
||||
async def child(data):
|
||||
d.event(data, 'api', 'review.point', 'succeeded')
|
||||
return 42
|
||||
|
||||
@d.observe('api', 'review.owner')
|
||||
async def call(owner):
|
||||
return await child({})
|
||||
|
||||
@d.observe('run', 'review.stream')
|
||||
async def stream(owner):
|
||||
yield await child({})
|
||||
|
||||
parent = d.Span(a.diagnostics, 'api', 'review.parent', {})
|
||||
with parent.activate():
|
||||
assert await call(shapes[shape]) == 42
|
||||
assert [x async for x in stream(shapes[shape])] == [42]
|
||||
d.event(shapes[shape], 'api', 'review.point', 'succeeded')
|
||||
assert len(a.diagnostics.pending) == 1
|
||||
assert not b.diagnostics.pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_context_cannot_select_other_instance_or_owned_workspace():
|
||||
ap = make_ap()
|
||||
ca, cb = context(), context('instance-other')
|
||||
|
||||
@d.observe('api', 'review.context')
|
||||
async def call(owner, execution_context):
|
||||
return 42
|
||||
|
||||
owner = NS(ap=ap, execution_context=ca)
|
||||
with d.Span(ap.diagnostics, 'api', 'review.parent', {'workspace_uuid': ca.workspace_uuid}).activate():
|
||||
assert await call(owner, cb) == 42
|
||||
assert await call(owner, context()) == 42
|
||||
assert not [e for e in ap.diagnostics.pending if e['operation'] == 'review.context']
|
||||
# A misplaced manager attachment is not an authoritative manager for B.
|
||||
other = make_ap('instance-other')
|
||||
other.diagnostics = ap.diagnostics
|
||||
assert await call(NS(ap=other), cb) == 42
|
||||
assert not [e for e in ap.diagnostics.pending if e['operation'] == 'review.context']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
@pytest.mark.parametrize('action', ['aclose', 'athrow_exit', 'athrow_value', 'athrow_cancel'])
|
||||
async def test_cleanup_exception_identity_matches_native(disabled, action):
|
||||
ap = make_ap(disabled=disabled)
|
||||
cleanup_error = ValueError('CANARY cleanup')
|
||||
|
||||
async def original(owner):
|
||||
try:
|
||||
yield 1
|
||||
finally:
|
||||
raise cleanup_error
|
||||
|
||||
for fn in (original, d.observe('run', 'review.cleanup')(original)):
|
||||
gen = fn(ap)
|
||||
assert isinstance(gen, AsyncGenerator)
|
||||
assert await anext(gen) == 1
|
||||
with pytest.raises(ValueError) as caught:
|
||||
if action == 'aclose':
|
||||
await gen.aclose()
|
||||
else:
|
||||
error = {
|
||||
'athrow_exit': GeneratorExit(),
|
||||
'athrow_value': KeyError('business'),
|
||||
'athrow_cancel': asyncio.CancelledError('cancel'),
|
||||
}[action]
|
||||
await gen.athrow(error)
|
||||
assert caught.value is cleanup_error
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_athrow_generator_exit_can_yield_and_close_remains_native(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
|
||||
async def original(owner):
|
||||
try:
|
||||
yield 1
|
||||
except GeneratorExit:
|
||||
yield 2
|
||||
yield 3
|
||||
|
||||
for fn in (original, d.observe('run', 'review.exit')(original)):
|
||||
gen = fn(ap)
|
||||
assert await anext(gen) == 1
|
||||
assert await gen.athrow(GeneratorExit()) == 2
|
||||
assert await anext(gen) == 3
|
||||
await gen.aclose()
|
||||
gen = fn(ap)
|
||||
assert await anext(gen) == 1
|
||||
with pytest.raises(RuntimeError, match='ignored GeneratorExit'):
|
||||
await gen.aclose()
|
||||
# The native generator is still suspended after the refused close.
|
||||
assert await anext(gen) == 3
|
||||
with pytest.raises(StopAsyncIteration):
|
||||
await anext(gen)
|
||||
await gen.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_generator_send_throw_cancellation_and_primary_exception(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
entered = asyncio.Event()
|
||||
primary = ValueError('CANARY business')
|
||||
|
||||
async def original(owner):
|
||||
value = yield 1
|
||||
try:
|
||||
yield value
|
||||
except KeyError:
|
||||
yield 3
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
for fn in (original, d.observe('run', 'review.protocol')(original)):
|
||||
gen = fn(ap)
|
||||
assert await anext(gen) == 1
|
||||
assert await gen.asend(7) == 7
|
||||
assert await gen.athrow(KeyError('throw')) == 3
|
||||
entered.clear()
|
||||
task = asyncio.create_task(anext(gen))
|
||||
await entered.wait()
|
||||
task.cancel('native cancellation')
|
||||
with pytest.raises(asyncio.CancelledError, match='native cancellation'):
|
||||
await task
|
||||
await gen.aclose()
|
||||
|
||||
async def failing(owner):
|
||||
yield 1
|
||||
raise primary
|
||||
|
||||
gen = d.observe('run', 'review.primary')(failing)(ap)
|
||||
assert await anext(gen) == 1
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await anext(gen)
|
||||
assert caught.value is primary
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_kook_native_entry_conversion_failure_has_trusted_workspace():
|
||||
from langbot.pkg.platform.adapters.kook.adapter import KookAdapter
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
ap, ctx = make_ap(), context()
|
||||
logger = EventLogger('test', ap, ctx, 'test')
|
||||
logger.error = AsyncMock()
|
||||
adapter = KookAdapter({'token': 'test-placeholder'}, logger)
|
||||
# Invalid native timestamp fails inside the real static converter.
|
||||
await adapter._handle_event({'type': 255, 'msg_timestamp': 'CANARY invalid', 'workspace_uuid': str(uuid4())}, 1)
|
||||
logger.error.assert_awaited_once()
|
||||
failures = [e for e in ap.diagnostics.pending if e['outcome'] == 'failed']
|
||||
assert any(e['stage'] == 'convert' for e in failures)
|
||||
assert any(e['operation'] == 'platform.receive' for e in failures)
|
||||
assert all(e['workspace_uuid'] == ctx.workspace_uuid for e in ap.diagnostics.pending)
|
||||
assert all(e['adapter'] == 'kook-omni' for e in ap.diagnostics.pending)
|
||||
assert 'CANARY' not in json.dumps(ap.diagnostics.pending)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def boot_stages(monkeypatch):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
stages_run = []
|
||||
|
||||
class Stage:
|
||||
async def run(self, app):
|
||||
stages_run.append(app)
|
||||
|
||||
# Earlier registry tests clear this shared dictionary; cached imports do not
|
||||
# re-register stages. Own the registry per test and restore it on teardown.
|
||||
monkeypatch.setattr(boot.stage, 'preregistered_stages', {'LoadConfigStage': Stage, 'GenKeysStage': Stage})
|
||||
return stages_run
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('failure', ['constructor', 'metadata', 'session', 'start'])
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_real_make_app_optional_initialization_fail_open(monkeypatch, boot_stages, failure, disabled):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
ap = NS(
|
||||
instance_config=NS(data={'space': {'url': 'https://example.invalid', 'disable_telemetry': disabled}}),
|
||||
initialize=AsyncMock(),
|
||||
shutdown=AsyncMock(),
|
||||
)
|
||||
stages_run = boot_stages
|
||||
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||
monkeypatch.setattr(boot, 'stage_order', ['LoadConfigStage', 'GenKeysStage'])
|
||||
error = RuntimeError('optional diagnostics')
|
||||
if failure == 'constructor':
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(side_effect=error))
|
||||
elif failure == 'metadata':
|
||||
monkeypatch.setattr(
|
||||
importlib.metadata, 'version', Mock(side_effect=importlib.metadata.PackageNotFoundError('langbot'))
|
||||
)
|
||||
else:
|
||||
manager = NS(
|
||||
start_session=AsyncMock(), start=Mock(), shutdown=AsyncMock(side_effect=RuntimeError('optional cleanup'))
|
||||
)
|
||||
getattr(manager, 'start_session' if failure == 'session' else 'start').side_effect = error
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(return_value=manager))
|
||||
assert await boot.make_app(asyncio.get_running_loop()) is ap
|
||||
assert len(stages_run) == 2
|
||||
ap.initialize.assert_awaited_once()
|
||||
ap.shutdown.assert_not_awaited()
|
||||
assert getattr(ap, 'diagnostics', None) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_make_app_session_cancellation_and_shutdown_failure_preserve_primary(monkeypatch, boot_stages):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
cancelled = asyncio.CancelledError('genuine cancellation')
|
||||
ap = NS(
|
||||
instance_config=NS(data={'space': {'url': 'https://example.invalid'}}),
|
||||
initialize=AsyncMock(),
|
||||
shutdown=AsyncMock(side_effect=RuntimeError('shutdown error')),
|
||||
)
|
||||
manager = NS(start_session=AsyncMock(side_effect=cancelled), start=Mock(), shutdown=AsyncMock())
|
||||
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||
monkeypatch.setattr(boot, 'stage_order', ['GenKeysStage'])
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', Mock(return_value=manager))
|
||||
with pytest.raises(asyncio.CancelledError) as caught:
|
||||
await boot.make_app(asyncio.get_running_loop())
|
||||
assert caught.value is cancelled
|
||||
ap.initialize.assert_not_awaited()
|
||||
ap.shutdown.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_make_app_business_failure_not_masked_by_optional_shutdown(monkeypatch, boot_stages):
|
||||
from langbot.pkg.core import boot
|
||||
|
||||
primary = ValueError('business startup')
|
||||
ap = boot.app.Application()
|
||||
ap.instance_config = NS(data={'space': {'url': 'https://example.invalid'}})
|
||||
ap.initialize = AsyncMock(side_effect=primary)
|
||||
manager = make_ap().diagnostics
|
||||
manager.start_session = AsyncMock()
|
||||
manager.start = Mock()
|
||||
manager.shutdown = AsyncMock(side_effect=RuntimeError('optional shutdown'))
|
||||
monkeypatch.setattr(boot.app, 'Application', lambda: ap)
|
||||
monkeypatch.setattr(boot.diagnostics, 'DiagnosticsManager', lambda *a, **kw: manager)
|
||||
monkeypatch.setattr(boot, 'stage_order', ['GenKeysStage'])
|
||||
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await boot.make_app(asyncio.get_running_loop())
|
||||
assert caught.value is primary
|
||||
manager.shutdown.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_protocol_rejected_calls_and_unstarted_throw_match_native(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
|
||||
async def original(owner):
|
||||
try:
|
||||
yield 1
|
||||
except GeneratorExit:
|
||||
yield 2
|
||||
yield 3
|
||||
|
||||
async def record(fn, actions):
|
||||
gen = fn(ap)
|
||||
results = []
|
||||
for name, values in actions:
|
||||
try:
|
||||
results.append(('value', await getattr(gen, name)(*values)))
|
||||
except BaseException as exc:
|
||||
results.append((type(exc).__name__, str(exc)))
|
||||
# Fully exhaust any generator left suspended by a refused close.
|
||||
try:
|
||||
while True:
|
||||
await anext(gen)
|
||||
except (StopAsyncIteration, GeneratorExit):
|
||||
pass
|
||||
return results
|
||||
|
||||
wrapped = d.observe('run', 'review.protocol_matrix')(original)
|
||||
for actions in (
|
||||
[('aclose', ())],
|
||||
[('athrow', (GeneratorExit(),))],
|
||||
[('athrow', (ValueError('unstarted'),))],
|
||||
[('asend', (7,)), ('__anext__', ()), ('athrow', (ValueError('primary'),))],
|
||||
[('__anext__', ()), ('aclose', ()), ('__anext__', ()), ('aclose', ())],
|
||||
[('__anext__', ()), ('athrow', (GeneratorExit(),)), ('__anext__', ()), ('aclose', ())],
|
||||
):
|
||||
assert await record(wrapped, actions) == await record(original, actions)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('disabled', [False, True])
|
||||
async def test_native_task_cancel_cleanup_error_wins_without_extra_close(disabled):
|
||||
ap = make_ap(disabled=disabled)
|
||||
error = ValueError('native cleanup')
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def original(owner):
|
||||
yield 1
|
||||
try:
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
raise error
|
||||
|
||||
for fn in (original, d.observe('run', 'review.cancel_cleanup')(original)):
|
||||
gen = fn(ap)
|
||||
await anext(gen)
|
||||
entered.clear()
|
||||
task = asyncio.create_task(anext(gen))
|
||||
await entered.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(ValueError) as caught:
|
||||
await task
|
||||
assert caught.value is error
|
||||
await gen.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_b2_handler_consumes_observed_async_generator(tmp_path):
|
||||
from langbot_plugin.runtime.io.handler import Handler, ActionResponse
|
||||
from langbot_plugin.entities.io.resp import ChunkStatus
|
||||
|
||||
ap = make_ap()
|
||||
handler = Handler(NS(), file_storage_dir=str(tmp_path))
|
||||
handler._send_message = AsyncMock()
|
||||
|
||||
@d.observe('api', 'host.review_stream', ap=ap)
|
||||
async def stream(data):
|
||||
yield ActionResponse.success({'business': 'unchanged'})
|
||||
|
||||
handler.actions['review_stream'] = stream
|
||||
await handler._handle_action({'seq_id': 1, 'action': 'review_stream', 'data': {}})
|
||||
responses = [call.args[0] for call in handler._send_message.await_args_list]
|
||||
assert len(responses) == 2
|
||||
assert responses[0].data == {'business': 'unchanged'}
|
||||
assert [response.chunk_status for response in responses] == [ChunkStatus.CONTINUE, ChunkStatus.END]
|
||||
assert [e['outcome'] for e in ap.diagnostics.pending] == ['started', 'succeeded']
|
||||
@@ -0,0 +1,350 @@
|
||||
"""Content-free diagnostics contract and lifecycle regression tests."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.telemetry import diagnostics as d
|
||||
|
||||
|
||||
def manager(version='4.11.0-beta.2', **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=4)
|
||||
return ap.diagnostics
|
||||
|
||||
|
||||
def test_release_gate_and_privacy():
|
||||
assert not manager('4.11.0').enabled
|
||||
assert manager().enabled
|
||||
assert manager('4.11.0b2').enabled
|
||||
assert not manager(disable_telemetry=True).enabled
|
||||
assert not manager(disable_beta_diagnostics=True).enabled
|
||||
m = manager()
|
||||
m.emit(
|
||||
'api',
|
||||
'test.operation',
|
||||
'failed',
|
||||
attributes={'prompt': 'CANARY', 'plugin_id': 'CANARY', 'attempts': 1},
|
||||
error=ValueError('CANARY https://secret/token'),
|
||||
workspace_uuid=str(uuid4()),
|
||||
)
|
||||
payload = m.pending[0]
|
||||
assert 'CANARY' not in json.dumps(payload)
|
||||
assert payload['attributes'] == {'attempts': 1}
|
||||
assert payload['error_type'] == 'ValueError'
|
||||
assert payload['instance_id'] != payload['workspace_uuid']
|
||||
assert payload['sample_rate'] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_boundary_skips_all_projection(monkeypatch):
|
||||
m = manager('4.11.0')
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise AssertionError('diagnostic machinery ran while disabled')
|
||||
|
||||
monkeypatch.setattr(d, 'Span', broken)
|
||||
monkeypatch.setattr(d, 'result_outcome', broken)
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('api', 'test.disabled', fields=broken)
|
||||
async def call(self):
|
||||
return 42
|
||||
|
||||
@d.observe('run', 'test.disabled', fields=broken)
|
||||
async def stream(self):
|
||||
yield 42
|
||||
|
||||
assert await Service().call() == 42
|
||||
assert [v async for v in Service().stream()] == [42]
|
||||
assert not m.pending
|
||||
|
||||
|
||||
def test_bounds_and_disable_clear():
|
||||
m = manager()
|
||||
for _ in range(8):
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
assert len(m.pending) == 4
|
||||
assert m.counters['dropped'] == 4
|
||||
m.ap.instance_config.data['space']['disable_telemetry'] = True
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
assert not m.pending
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_ack_retries_same_identity_and_drops_rejected():
|
||||
m = manager()
|
||||
for _ in range(3):
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
ids = [e['event_id'] for e in m.pending]
|
||||
requests = []
|
||||
|
||||
async def handler(req):
|
||||
requests.append(json.loads(req.content))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
'code': 200,
|
||||
'data': {'accepted_event_ids': [ids[0]], 'rejected': [{'event_id': ids[1], 'code': 'invalid_event'}]},
|
||||
},
|
||||
)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
assert [e['event_id'] for e in m.pending] == [ids[2]]
|
||||
assert m.counters['acked'] == 1
|
||||
assert m.counters['dropped'] == 1
|
||||
assert requests[0]['schema_version'] == 1
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outage_finite_retry_and_slow_credentials():
|
||||
m = manager()
|
||||
m.max_attempts = 2
|
||||
m.emit('run', 'test.operation', 'started')
|
||||
calls = []
|
||||
|
||||
async def handler(req):
|
||||
calls.append(req)
|
||||
return httpx.Response(503)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
await m.flush_once()
|
||||
assert not m.pending
|
||||
assert m.counters['dropped'] == 1
|
||||
assert len(calls) == 2
|
||||
|
||||
async def credentials(workspace):
|
||||
await asyncio.sleep(60)
|
||||
|
||||
m.credentials = credentials
|
||||
m.request_timeout = 0.01
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
await asyncio.wait_for(m.flush_once(), 0.2)
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inflight_disable_cancels_and_clears():
|
||||
m = manager()
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def handler(req):
|
||||
entered.set()
|
||||
await asyncio.sleep(60)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
m.start()
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
await asyncio.wait_for(entered.wait(), 1)
|
||||
m.ap.instance_config.data['space']['disable_beta_diagnostics'] = True
|
||||
await asyncio.sleep(0.3)
|
||||
assert not m.pending
|
||||
await asyncio.wait_for(m.shutdown(drain_timeout=0), 0.5)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observe_returns_errors_and_cancellation():
|
||||
m = manager()
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('api', 'test.operation')
|
||||
async def call(self, error=None):
|
||||
if error:
|
||||
raise error
|
||||
return {'secret': 'CANARY'}
|
||||
|
||||
s = Service()
|
||||
assert await s.call() == {'secret': 'CANARY'}
|
||||
with pytest.raises(ValueError):
|
||||
await s.call(ValueError('CANARY'))
|
||||
m.pending.clear()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await s.call(asyncio.CancelledError())
|
||||
assert m.pending[-1]['outcome'] == 'cancelled'
|
||||
assert 'CANARY' not in json.dumps(m.pending)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generator_send_throw_close_and_context_isolation():
|
||||
m = manager()
|
||||
m.capacity = 30
|
||||
closed = []
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('run', 'test.operation')
|
||||
async def stream(self):
|
||||
try:
|
||||
value = yield 1
|
||||
try:
|
||||
yield value
|
||||
except ValueError:
|
||||
yield 3
|
||||
finally:
|
||||
closed.append(True)
|
||||
|
||||
gen = Service().stream()
|
||||
assert await anext(gen) == 1
|
||||
assert d.current_span() is None
|
||||
assert await gen.asend(7) == 7
|
||||
assert await gen.athrow(ValueError('CANARY')) == 3
|
||||
await gen.aclose()
|
||||
assert closed == [True]
|
||||
assert m.pending[-1]['outcome'] == 'cancelled'
|
||||
assert d.current_span() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generator_early_failure_and_explicit_terminal():
|
||||
m = manager()
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('run', 'test.operation')
|
||||
async def stream(self, fail):
|
||||
if fail:
|
||||
raise ValueError('prepare CANARY')
|
||||
d.set_outcome('failed', reason_code='runner_failed')
|
||||
yield 1
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await anext(Service().stream(True))
|
||||
assert m.pending[-1]['outcome'] == 'failed'
|
||||
m.pending.clear()
|
||||
assert [v async for v in Service().stream(False)] == [1]
|
||||
assert m.pending[-1]['outcome'] == 'failed'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projection_fault_does_not_replace_business_return(monkeypatch):
|
||||
m = manager()
|
||||
|
||||
def broken(*args, **kwargs):
|
||||
raise RuntimeError('CANARY projection')
|
||||
|
||||
monkeypatch.setattr(d, 'result_outcome', broken)
|
||||
|
||||
class Service:
|
||||
ap = m.ap
|
||||
|
||||
@d.observe('api', 'test.projection_fault')
|
||||
async def call(self):
|
||||
return 42
|
||||
|
||||
assert await Service().call() == 42
|
||||
assert m.pending[-1]['outcome'] == 'succeeded'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_batches_and_credential_failure_are_anonymous():
|
||||
m = manager()
|
||||
workspaces = [str(uuid4()), str(uuid4())]
|
||||
for workspace in workspaces:
|
||||
m.emit('api', 'test.operation', 'succeeded', workspace_uuid=workspace)
|
||||
requests = []
|
||||
|
||||
async def broken(workspace):
|
||||
raise RuntimeError('CANARY credentials')
|
||||
|
||||
m.credentials = broken
|
||||
|
||||
async def handler(request):
|
||||
data = json.loads(request.content)
|
||||
requests.append((data, dict(request.headers)))
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={'code': 200, 'data': {'accepted_event_ids': [e['event_id'] for e in data['events']], 'rejected': []}},
|
||||
)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
await m.flush_once()
|
||||
assert len(requests) == 2
|
||||
assert [request[0]['events'][0]['workspace_uuid'] for request in requests] == workspaces
|
||||
assert all('authorization' not in headers for _, headers in requests)
|
||||
assert not m.pending
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summary_is_interval_delta_and_ack_replay_is_idempotent():
|
||||
m = manager()
|
||||
m.capacity = 20
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
m.report_transport()
|
||||
first = m.pending[-1]
|
||||
m.report_transport()
|
||||
second = m.pending[-1]
|
||||
assert first['attributes']['generated'] == 1
|
||||
assert second['attributes']['generated'] == 1 # Only first summary itself.
|
||||
assert first['event_id'] != second['event_id']
|
||||
ids = [e['event_id'] for e in m.pending]
|
||||
seen = []
|
||||
|
||||
async def handler(request):
|
||||
body = json.loads(request.content)
|
||||
seen.append([e['event_id'] for e in body['events']])
|
||||
return (
|
||||
httpx.Response(503)
|
||||
if len(seen) == 1
|
||||
else httpx.Response(200, json={'code': 200, 'data': {'accepted_event_ids': ids + ids, 'rejected': []}})
|
||||
)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
await m.flush_once()
|
||||
await m.flush_once()
|
||||
assert seen == [ids, ids]
|
||||
assert m.counters['acked'] == len(ids)
|
||||
assert not m.pending
|
||||
await m.shutdown(drain_timeout=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retention_and_bounded_shutdown():
|
||||
m = manager()
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
m.retention_seconds = -1
|
||||
await m.flush_once()
|
||||
assert not m.pending and m.counters['dropped'] == 1
|
||||
m.retention_seconds = 900
|
||||
|
||||
async def handler(request):
|
||||
await asyncio.sleep(60)
|
||||
return httpx.Response(503)
|
||||
|
||||
m.client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||
m.emit('api', 'test.operation', 'succeeded')
|
||||
await asyncio.wait_for(m.shutdown(drain_timeout=0.01), 0.5)
|
||||
assert not m.pending and m.client.is_closed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_marker_restart_and_disable(tmp_path):
|
||||
marker = tmp_path / 'session.json'
|
||||
m = manager()
|
||||
m.marker_path = marker
|
||||
await m.start_session()
|
||||
assert marker.exists()
|
||||
recovered = manager()
|
||||
recovered.marker_path = marker
|
||||
await recovered.start_session()
|
||||
assert recovered.pending[-1]['attributes']['previous_session_unclean'] is True
|
||||
recovered.ap.instance_config.data['space']['disable_beta_diagnostics'] = True
|
||||
recovered.start()
|
||||
await asyncio.sleep(0.2)
|
||||
assert not marker.exists()
|
||||
await recovered.shutdown(drain_timeout=0)
|
||||
await m.shutdown(drain_timeout=0)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Release artifacts must identify their actual source, not a branch label."""
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SPEC = importlib.util.spec_from_file_location('stamp_build_revision', ROOT / 'scripts/stamp_build_revision.py')
|
||||
assert SPEC is not None and SPEC.loader is not None
|
||||
MODULE = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def test_stamp_accepts_full_revision(tmp_path):
|
||||
target = tmp_path / 'src/langbot/_build_info.py'
|
||||
target.parent.mkdir(parents=True)
|
||||
revision = 'a1' * 20
|
||||
assert MODULE.stamp(tmp_path, revision) == revision
|
||||
assignments = [node for node in ast.parse(target.read_text()).body if isinstance(node, ast.Assign)]
|
||||
assert len(assignments) == 1
|
||||
assert ast.literal_eval(assignments[0].value) == revision
|
||||
|
||||
|
||||
@pytest.mark.parametrize('revision', ['main', 'abc123', 'A' * 40, 'a' * 39, 'a' * 41, '../secret', 'a' * 40 + '\n'])
|
||||
def test_stamp_rejects_non_revision_before_write(tmp_path, revision):
|
||||
with pytest.raises(ValueError):
|
||||
MODULE.stamp(tmp_path, revision)
|
||||
assert not (tmp_path / 'src').exists()
|
||||
Reference in New Issue
Block a user