mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-28 05:07:14 +00:00
feat(wizard): rework agent onboarding flow (#2471)
* feat(wizard): rework agent onboarding flow * fix(web): support LAN development access * fix(wizard): parse ranked model selection entries * feat(wizard): add inbound bot verification * feat(wizard): add floating page bot verification * fix(wizard): repair HTTP bot inbound test setup * feat(wizard): streamline custom model onboarding * feat(wizard): label page bot test preview * style(space): apply ruff formatting * fix(wizard): polish AI engine onboarding * fix(wizard): clarify local account message test * feat(wizard): animate AI engine transitions * fix(wizard): align AI engine setup headers --------- Co-authored-by: langbot-dev <langbot@users.noreply.github.com> Co-authored-by: RockChinQ <rockchinq@gmail.com>
This commit is contained in:
@@ -254,6 +254,22 @@ class TestPipelinesCRUDEndpoints:
|
||||
assert data['code'] == 0
|
||||
assert 'uuid' in data['data']
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_default_pipeline_forwards_default_flag(self, quart_test_client, fake_pipeline_app):
|
||||
"""POST /api/v1/pipelines explicitly creates a default pipeline."""
|
||||
fake_pipeline_app.pipeline_service.create_pipeline.reset_mock()
|
||||
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/pipelines',
|
||||
headers={'Authorization': 'Bearer test_token'},
|
||||
json={'name': 'Default Pipeline', 'config': {}, 'is_default': True},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
call = fake_pipeline_app.pipeline_service.create_pipeline.await_args
|
||||
assert call.kwargs == {'default': True}
|
||||
assert call.args[1]['is_default'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_pipeline_success(self, quart_test_client):
|
||||
"""PUT /api/v1/pipelines/{uuid} updates pipeline."""
|
||||
|
||||
@@ -9,8 +9,9 @@ Source: src/langbot/pkg/api/http/service/bot.py
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
from types import SimpleNamespace
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from langbot.pkg.api.http.service.bot import BotService
|
||||
@@ -241,6 +242,29 @@ class TestBotServiceGetRuntimeBotInfo:
|
||||
assert result['adapter_runtime_values']['webhook_url'] == '/bots/wecom-uuid'
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == 'http://127.0.0.1:5300/bots/wecom-uuid'
|
||||
|
||||
async def test_get_runtime_bot_info_returns_webhook_for_http_bot(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(
|
||||
data={'api': {'webhook_prefix': 'https://bot.example.com'}}
|
||||
),
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=None)),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'name': 'HTTP Bot',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {},
|
||||
}
|
||||
)
|
||||
|
||||
result = await service.get_runtime_bot_info(WORKSPACE_UUID, 'http-bot-uuid')
|
||||
|
||||
assert result['adapter_runtime_values']['webhook_full_url'] == (
|
||||
'https://bot.example.com/bots/http-bot-uuid'
|
||||
)
|
||||
|
||||
async def test_get_runtime_bot_info_no_webhook_for_telegram(self):
|
||||
"""Returns no webhook URL for non-webhook adapters like telegram."""
|
||||
# Setup
|
||||
@@ -605,6 +629,77 @@ class TestBotServiceListEventLogs:
|
||||
assert total == 5
|
||||
|
||||
|
||||
class TestBotServiceHttpBotInboundTest:
|
||||
async def test_sends_signed_message_through_public_ingress(self):
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={'api': {'port': 5300}}),
|
||||
)
|
||||
service = BotService(ap)
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'http-bot-uuid',
|
||||
'adapter': 'http_bot',
|
||||
'adapter_config': {
|
||||
'signature_required': True,
|
||||
'inbound_secret': 'test-secret',
|
||||
},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
response = MagicMock(status=202)
|
||||
session = MagicMock()
|
||||
session.post.return_value.__aenter__ = AsyncMock(return_value=response)
|
||||
session.post.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.bot.httpclient.get_session', return_value=session),
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.bot.httpclient.read_json_limited',
|
||||
new=AsyncMock(
|
||||
return_value={
|
||||
'code': 0,
|
||||
'data': {
|
||||
'session_id': 'wizard-session',
|
||||
'accepted_message_id': 'in-message',
|
||||
},
|
||||
}
|
||||
),
|
||||
),
|
||||
):
|
||||
result = await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'http-bot-uuid',
|
||||
'hello',
|
||||
)
|
||||
|
||||
assert result['accepted_message_id'] == 'in-message'
|
||||
request = session.post.call_args
|
||||
assert request.args[0] == 'http://127.0.0.1:5300/bots/http-bot-uuid'
|
||||
payload = json.loads(request.kwargs['data'])
|
||||
assert payload['message'] == [{'type': 'Plain', 'text': 'hello'}]
|
||||
headers = request.kwargs['headers']
|
||||
assert headers['X-LB-Timestamp']
|
||||
assert headers['X-LB-Signature'].startswith('sha256=')
|
||||
|
||||
async def test_rejects_non_http_bot(self):
|
||||
service = BotService(SimpleNamespace())
|
||||
service.get_bot = AsyncMock(
|
||||
return_value={
|
||||
'uuid': 'telegram-bot',
|
||||
'adapter': 'telegram',
|
||||
'adapter_config': {},
|
||||
'enable': True,
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match='only available for HTTP Bot'):
|
||||
await service.send_http_bot_test_message(
|
||||
WORKSPACE_UUID,
|
||||
'telegram-bot',
|
||||
'hello',
|
||||
)
|
||||
|
||||
|
||||
class TestBotServiceSendMessage:
|
||||
"""Tests for send_message method."""
|
||||
|
||||
|
||||
@@ -820,6 +820,100 @@ class TestSpaceServiceGetModels:
|
||||
await service.get_models()
|
||||
|
||||
|
||||
class TestSpaceServiceGetModelSelection:
|
||||
"""Tests for availability-ranked model selection."""
|
||||
|
||||
@pytest.mark.parametrize('response_shape', ['direct', 'models-envelope', 'availability-wrapper'])
|
||||
async def test_preserves_selection_order_and_category_query(self, response_shape):
|
||||
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
|
||||
service = SpaceService(ap)
|
||||
models = [
|
||||
{
|
||||
'uuid': 'best-model',
|
||||
'model_id': 'best-chat-model',
|
||||
'provider': 'provider-1',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
{
|
||||
'uuid': 'fallback-model',
|
||||
'model_id': 'fallback-chat-model',
|
||||
'provider': 'provider-2',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
if response_shape == 'models-envelope':
|
||||
data = {'models': models}
|
||||
elif response_shape == 'availability-wrapper':
|
||||
data = [
|
||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
||||
for index, model in enumerate(models)
|
||||
]
|
||||
else:
|
||||
data = models
|
||||
payload = {'code': 0, 'data': data}
|
||||
mock_response = MagicMock(status=200)
|
||||
|
||||
with (
|
||||
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
|
||||
patch(
|
||||
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
|
||||
new=AsyncMock(return_value=payload),
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
|
||||
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
get_session.return_value = session
|
||||
|
||||
result = await service.get_model_selection('chat')
|
||||
|
||||
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
|
||||
session.get.assert_called_once_with(
|
||||
'https://space.langbot.app/api/v1/models/selection',
|
||||
params={'category': 'chat'},
|
||||
)
|
||||
|
||||
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
|
||||
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
|
||||
persistence = SimpleNamespace(
|
||||
execute_async=AsyncMock(
|
||||
side_effect=[
|
||||
_create_mock_result(first_item=None),
|
||||
_create_mock_result(first_item=local_model),
|
||||
]
|
||||
)
|
||||
)
|
||||
model_mgr = SimpleNamespace(sync_new_models_from_space=AsyncMock())
|
||||
ap = SimpleNamespace(
|
||||
instance_config=SimpleNamespace(data={}),
|
||||
persistence_mgr=persistence,
|
||||
model_mgr=model_mgr,
|
||||
)
|
||||
service = SpaceService(ap)
|
||||
service.get_model_selection = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(uuid='best-upstream-uuid', model_id='best-chat-model'),
|
||||
SimpleNamespace(uuid='fallback-upstream-uuid', model_id='fallback-chat-model'),
|
||||
]
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
instance_uuid='instance',
|
||||
workspace_uuid='workspace',
|
||||
placement_generation=1,
|
||||
principal=SimpleNamespace(),
|
||||
entitlement_revision=0,
|
||||
)
|
||||
|
||||
result = await service.get_recommended_chat_model(context)
|
||||
|
||||
assert result == {'uuid': 'local-model-uuid', 'name': 'best-chat-model'}
|
||||
service.get_model_selection.assert_awaited_once_with('chat')
|
||||
model_mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
assert persistence.execute_async.await_count == 2
|
||||
|
||||
|
||||
class TestSpaceServiceCreditsCache:
|
||||
"""Tests for credits cache behavior."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user