mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-16 14:57:15 +00:00
fix(runtime): preserve explicit replies and report bot configuration errors
This commit is contained in:
@@ -151,7 +151,18 @@ class TestAgentServiceMetadata:
|
||||
|
||||
class TestAgentServiceDebug:
|
||||
@pytest.mark.parametrize('streaming', [False, True])
|
||||
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self, streaming):
|
||||
@pytest.mark.parametrize(
|
||||
'result_type',
|
||||
[
|
||||
'tool.call.started',
|
||||
'tool.call.completed',
|
||||
'message.delta',
|
||||
'message.completed',
|
||||
'processor.log',
|
||||
'run.completed',
|
||||
],
|
||||
)
|
||||
async def test_debug_agent_runs_configured_runner_with_synthetic_event(self, streaming, result_type):
|
||||
app = _make_app()
|
||||
agent_config = _agent_row().config
|
||||
agent_config['allowed_platform_tools'] = ['platform_get_user_info']
|
||||
@@ -162,7 +173,7 @@ class TestAgentServiceDebug:
|
||||
agent_config['allowed_tools'] = ['exec', 'weather']
|
||||
|
||||
visible_event = {
|
||||
'type': 'tool.call.started',
|
||||
'type': result_type,
|
||||
'data': {'tool_name': 'exec', 'parameters': {'command': 'echo hi'}},
|
||||
}
|
||||
observer = AsyncMock() if streaming else None
|
||||
@@ -171,6 +182,9 @@ class TestAgentServiceDebug:
|
||||
assert binding.delivery_policy.enable_streaming is streaming
|
||||
await adapter_context['_result_observer']({**visible_event, 'private_context': 'must not leak'})
|
||||
await adapter_context['_result_observer']({'type': 'state.updated', 'data': {'private': True}})
|
||||
if streaming:
|
||||
# Debug events reach the client before the runner returns its final output.
|
||||
observer.assert_awaited_once_with(visible_event)
|
||||
yield SimpleNamespace(
|
||||
role='assistant',
|
||||
content='debug result',
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from importlib import import_module
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
|
||||
|
||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
||||
core_app_module.Application = object
|
||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
||||
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _create_test_client(bot_service: SimpleNamespace):
|
||||
app = quart.Quart(__name__)
|
||||
account = SimpleNamespace(
|
||||
uuid='account-test',
|
||||
user='test@example.com',
|
||||
)
|
||||
user_service = SimpleNamespace(
|
||||
get_authenticated_account=AsyncMock(return_value=account),
|
||||
)
|
||||
access = SimpleNamespace(
|
||||
workspace=SimpleNamespace(uuid='workspace-test'),
|
||||
membership=SimpleNamespace(
|
||||
uuid='membership-test',
|
||||
role='developer',
|
||||
projection_revision=1,
|
||||
),
|
||||
execution=SimpleNamespace(
|
||||
instance_uuid='instance-test',
|
||||
placement_generation=1,
|
||||
),
|
||||
)
|
||||
ap = SimpleNamespace(
|
||||
bot_service=bot_service,
|
||||
user_service=user_service,
|
||||
apikey_service=SimpleNamespace(authenticate_api_key=AsyncMock(return_value=None)),
|
||||
workspace_collaboration_service=SimpleNamespace(resolve_account_workspace=AsyncMock(return_value=access)),
|
||||
)
|
||||
BotsRouterGroup = import_module('langbot.pkg.api.http.controller.groups.platform.bots').BotsRouterGroup
|
||||
group = BotsRouterGroup(ap, app)
|
||||
await group.initialize()
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('method,path', [('post', '/api/v1/platform/bots'), ('put', '/api/v1/platform/bots/bot-1')])
|
||||
async def test_bot_config_error_preserves_details(method, path):
|
||||
error = ValueError('Lark missing required config: app_id, app_secret, bot_name')
|
||||
service = SimpleNamespace(create_bot=AsyncMock(side_effect=error), update_bot=AsyncMock(side_effect=error))
|
||||
client = await _create_test_client(service)
|
||||
response = await getattr(client, method)(
|
||||
path, json={'adapter_config': {}}, headers={'Authorization': 'Bearer token'}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = await response.get_json()
|
||||
assert body['code'] == 'invalid_bot_config'
|
||||
assert body['msg'] == str(error)
|
||||
|
||||
|
||||
async def test_bot_apply_failure_identifies_persisted_record():
|
||||
from langbot.pkg.api.http.service.bot_errors import BotApplyError
|
||||
|
||||
service = SimpleNamespace(create_bot=AsyncMock(side_effect=BotApplyError('Missing app_id', 'saved-bot')))
|
||||
client = await _create_test_client(service)
|
||||
response = await client.post('/api/v1/platform/bots', json={}, headers={'Authorization': 'Bearer token'})
|
||||
assert response.status_code == 400
|
||||
body = await response.get_json()
|
||||
assert body.pop('request_id')
|
||||
assert body == {
|
||||
'code': 'bot_apply_failed',
|
||||
'msg': 'Missing app_id',
|
||||
'data': {'uuid': 'saved-bot'},
|
||||
}
|
||||
|
||||
|
||||
async def test_unexpected_failure_keeps_request_reference_without_exception_details():
|
||||
client = await _create_test_client(
|
||||
SimpleNamespace(update_bot=AsyncMock(side_effect=RuntimeError('database password')))
|
||||
)
|
||||
response = await client.put('/api/v1/platform/bots/bot-1', json={}, headers={'Authorization': 'Bearer token'})
|
||||
body = await response.get_json()
|
||||
assert response.status_code == 500
|
||||
assert body['request_id']
|
||||
assert 'database password' not in str(body)
|
||||
|
||||
|
||||
async def test_invalid_request_body_is_actionable():
|
||||
service = SimpleNamespace(update_bot=AsyncMock())
|
||||
client = await _create_test_client(service)
|
||||
response = await client.put('/api/v1/platform/bots/bot-1', json=[], headers={'Authorization': 'Bearer token'})
|
||||
assert response.status_code == 400
|
||||
service.update_bot.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_runtime_error_redacts_persisted_secrets_on_partial_update():
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
from langbot.pkg.api.http.service.bot_errors import BotApplyError
|
||||
|
||||
ap = SimpleNamespace(
|
||||
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(rowcount=1))),
|
||||
platform_mgr=SimpleNamespace(
|
||||
remove_bot=AsyncMock(),
|
||||
load_bot=AsyncMock(side_effect=ValueError('Invalid app_secret: persisted-secret-value')),
|
||||
),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={'uuid': 'bot-1', 'adapter_config': {'app_secret': 'persisted-secret-value'}}
|
||||
)
|
||||
with pytest.raises(BotApplyError) as captured:
|
||||
await service.update_bot('workspace-test', 'bot-1', {'enable': True})
|
||||
assert str(captured.value) == 'Invalid app_secret: ***'
|
||||
assert captured.value.bot_uuid == 'bot-1'
|
||||
ap.persistence_mgr.execute_async.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_validation_error_does_not_include_input_values():
|
||||
import pydantic
|
||||
from langbot.pkg.api.http.service.bot_errors import bot_error_message
|
||||
|
||||
class Config(pydantic.BaseModel):
|
||||
app_secret: int
|
||||
|
||||
try:
|
||||
Config(app_secret='private-value')
|
||||
except pydantic.ValidationError as error:
|
||||
result = bot_error_message(error, {'app_secret': 'private-value'})
|
||||
assert 'app_secret' in result
|
||||
assert 'private-value' not in result
|
||||
assert 'input_value' not in result
|
||||
Reference in New Issue
Block a user