fix(runtime): preserve explicit replies and report bot configuration errors

This commit is contained in:
RockChinQ
2026-09-10 17:21:22 +08:00
parent b7a04f7a24
commit 8903a40c41
41 changed files with 1108 additions and 155 deletions
+1 -1
View File
@@ -232,4 +232,4 @@ line-ending = "auto"
[tool.uv.sources] [tool.uv.sources]
# Development contract: update to the matching SDK release before publishing. # Development contract: update to the matching SDK release before publishing.
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "f82b3ce935f9a33afee389fb39fe8dc29a45b615" } langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "ca0671b81db5b2ed937d01158a3982de3fd8500f" }
@@ -8,6 +8,9 @@ import typing
from langbot_plugin.api.entities.builtin.provider import message as provider_message from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query from langbot_plugin.api.entities.builtin.pipeline import query as pipeline_query
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from .reply_stream import ReplyStreamSession
from ...core import app from ...core import app
from ...api.http.context import ExecutionContext from ...api.http.context import ExecutionContext
from ...pipeline.pool import get_query_execution_context from ...pipeline.pool import get_query_execution_context
@@ -155,6 +158,16 @@ class AgentRunOrchestrator:
state_context = build_state_context(event, binding, descriptor) state_context = build_state_context(event, binding, descriptor)
run_id = context['run_id'] run_id = context['run_id']
context['context']['available_apis']['reply_stream'] = hasattr(PluginToRuntimeAction, 'REPLY_STREAM') and any(
tool.get('tool_name') == 'event_reply' and tool.get('tool_type') == 'platform'
for tool in resources.get('tools', [])
)
reply_streams = ReplyStreamSession(
event,
adapter=(adapter_context or {}).get('_delivery_adapter') or getattr(execution_query, 'adapter', None),
source=(adapter_context or {}).get('_platform_event')
or getattr((adapter_context or {}).get('_query'), 'message_event', None),
)
available_apis = context.get('context', {}).get('available_apis') available_apis = context.get('context', {}).get('available_apis')
run_authorization = { run_authorization = {
'runner_id': descriptor.id, 'runner_id': descriptor.id,
@@ -208,6 +221,7 @@ class AgentRunOrchestrator:
state_context=state_context, state_context=state_context,
execution_query=execution_query, execution_query=execution_query,
platform_context=freeze_platform_context(event), platform_context=freeze_platform_context(event),
reply_streams=reply_streams,
) )
event_log_id = await self.journal.write_event_log( event_log_id = await self.journal.write_event_log(
@@ -358,6 +372,7 @@ class AgentRunOrchestrator:
raise raise
finally: finally:
session = await self._session_registry.unregister(run_id) session = await self._session_registry.unregister(run_id)
await reply_streams.close()
pending_steering = session.get('steering_queue', []) if session else [] pending_steering = session.get('steering_queue', []) if session else []
if pending_steering: if pending_steering:
try: try:
@@ -54,8 +54,10 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
'write', 'write',
{'zh_Hans': '回复当前会话', 'en_US': 'Reply to current conversation'}, {'zh_Hans': '回复当前会话', 'en_US': 'Reply to current conversation'},
{ {
'zh_Hans': '向触发当前事件的会话发送文本消息。目标由 LangBot 固定,Agent 无法改写。', 'zh_Hans': '向触发当前事件的会话发送回复或任务进度。目标由 LangBot 固定,Agent 无法改写。'
'en_US': 'Send text to the conversation that triggered this run. LangBot fixes the target.', '需要发送消息时请调用此工具,Agent 的输出文本不会自动发送。',
'en_US': 'Send a reply or task progress to the conversation that triggered this run. LangBot fixes the target. '
'Call this tool to send a message; Agent output text is not sent automatically.',
}, },
_object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']), _object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']),
('message.*', 'friend.*', 'group.*', 'feedback.*'), ('message.*', 'friend.*', 'group.*', 'feedback.*'),
@@ -209,8 +211,9 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = (
'write', 'write',
{'zh_Hans': '发送消息', 'en_US': 'Send message'}, {'zh_Hans': '发送消息', 'en_US': 'Send message'},
{ {
'zh_Hans': '使用当前机器人向指定用户或群组发送文本消息', 'zh_Hans': '使用当前机器人向指定用户或群组发送回复或任务进度。Agent 的输出文本不会自动发送',
'en_US': 'Send text to a specified person or group using the current bot.', 'en_US': 'Send a reply or task progress to a specified person or group using the current bot. '
'Agent output text is not sent automatically.',
}, },
_object_schema( _object_schema(
{'target_type': _TARGET_TYPE, 'target_id': _ID, 'text': _TEXT}, ['target_type', 'target_id', 'text'] {'target_type': _TARGET_TYPE, 'target_id': _ID, 'text': _TEXT}, ['target_type', 'target_id', 'text']
@@ -0,0 +1,162 @@
"""Host-owned explicit replies using the existing adapter streaming lifecycle."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import Literal
from uuid import UUID, uuid4
import pydantic
from langbot_plugin.api.entities.builtin.platform import events, message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
class ReplyStreamRequest(pydantic.BaseModel):
model_config = pydantic.ConfigDict(extra='forbid')
stream_id: UUID
operation: Literal['update', 'finish', 'abort']
text: str = pydantic.Field(max_length=200_000, strict=True)
@dataclass
class _Reply:
delivery_id: str = field(default_factory=lambda: str(uuid4()))
text: str = ''
started: bool = False
native: bool = False
final_attempted: bool = False
sequence: int = 0
status: str = 'open'
result: dict | None = None
class ReplyStreamSession:
"""Ephemeral delivery state owned by one authorized run, never by plugin input."""
def __init__(self, event, adapter=None, source=None):
self.adapter = adapter
self.source = source.to_legacy_event() if isinstance(source, events.MessageReceivedEvent) else source
if not isinstance(self.source, events.MessageEvent):
self.source = None
self.target = dict(event.delivery.reply_target or {})
self.mock = (
event.delivery.surface == 'webui' and (event.delivery.platform_capabilities or {}).get('debug_mock') is True
)
self.mock_error = (
((event.delivery.platform_capabilities or {}).get('mock_options') or {})
.get('errors', {})
.get('event_reply')
)
self._replies: dict[str, _Reply] = {}
self._lock = asyncio.Lock()
self._closed = False
self._active: set[asyncio.Task] = set()
async def apply(self, request: ReplyStreamRequest) -> dict:
task = asyncio.create_task(self._apply(request))
self._active.add(task)
try:
return await task
finally:
self._active.discard(task)
async def _apply(self, request: ReplyStreamRequest) -> dict:
async with self._lock:
if self._closed:
raise ValueError('Reply stream run has ended')
key = str(request.stream_id)
reply = self._replies.get(key)
if reply is None:
if len(self._replies) >= 16:
raise ValueError('A run may create at most 16 reply streams')
reply = self._replies[key] = _Reply()
if reply.status != 'open':
if request.operation == 'update' or reply.status == 'failed':
raise ValueError('Reply stream is closed')
return reply.result
try:
if request.operation != 'abort':
reply.text = request.text
if self.mock_error:
raise ValueError(str(self.mock_error))
if not self.mock and reply.text and not reply.started and request.operation != 'abort':
if self.adapter is None:
raise ValueError('This run has no platform delivery adapter')
# Set before I/O: an uncertain create must never be retried as another send.
reply.started = True
reply.native = self.source is not None and await self.adapter.is_stream_output_supported()
if reply.native:
await self.adapter.create_message_card(reply.delivery_id, self.source)
if reply.started and reply.native:
await self._update_native(key, reply, final=request.operation != 'update')
elif request.operation == 'finish' and reply.text and not self.mock:
if self.source is not None:
await self.adapter.reply_message(
message_source=self.source, message=self._message(reply.text), quote_origin=False
)
else:
target_type, target_id = self.target.get('target_type'), self.target.get('target_id')
if not target_type or not target_id:
raise ValueError('This event has no reply target')
await self.adapter.send_message(str(target_type), str(target_id), self._message(reply.text))
if request.operation != 'update':
reply.status = 'completed' if request.operation == 'finish' else 'cancelled'
reply.result = {
'stream_id': key,
'status': reply.status,
'delivery': 'simulated' if self.mock else 'streaming' if reply.native else 'buffered',
'mock': self.mock,
**({'text': reply.text} if request.operation == 'finish' else {}),
}
return reply.result
except BaseException:
reply.status = 'failed'
raise
@staticmethod
def _message(text):
return message.MessageChain([message.Plain(text=text)])
async def _update_native(self, key, reply, *, final):
if final:
reply.final_attempted = True
reply.sequence += 1
chunk = provider_message.MessageChunk(
role='assistant',
content=reply.text,
all_content=reply.text,
resp_message_id=reply.delivery_id,
msg_sequence=reply.sequence,
is_final=final,
)
await self.adapter.reply_message_chunk(
message_source=self.source,
bot_message=chunk,
message=self._message(reply.text),
quote_origin=False,
is_final=final,
)
async def close(self):
"""Close visible streams on cancellation; never send a buffered partial reply."""
self._closed = True
pending_requests = list(self._active)
for task in pending_requests:
task.cancel()
if pending_requests:
await asyncio.gather(*pending_requests, return_exceptions=True)
async with self._lock:
pending = [
self._update_native(key, reply, final=True)
for key, reply in self._replies.items()
if reply.started and reply.native and not reply.final_attempted
]
try:
if pending:
await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), timeout=5)
except TimeoutError:
pass
finally:
self._replies.clear()
@@ -77,6 +77,7 @@ class AgentRunSession(typing.TypedDict):
plugin_identity: str # author/name plugin_identity: str # author/name
authorization: RunAuthorizationSnapshot authorization: RunAuthorizationSnapshot
status: AgentRunSessionStatus status: AgentRunSessionStatus
reply_streams: typing.Any
steering_queue: list[SteeringQueueItem] steering_queue: list[SteeringQueueItem]
@@ -115,6 +116,7 @@ class AgentRunSessionRegistry:
state_context: dict[str, typing.Any] | None = None, state_context: dict[str, typing.Any] | None = None,
execution_query: pipeline_query.Query | None = None, execution_query: pipeline_query.Query | None = None,
platform_context: dict[str, typing.Any] | None = None, platform_context: dict[str, typing.Any] | None = None,
reply_streams: typing.Any = None,
) -> None: ) -> None:
"""Register a new agent run session. """Register a new agent run session.
@@ -167,6 +169,7 @@ class AgentRunSessionRegistry:
'runner_id': runner_id, 'runner_id': runner_id,
'query_id': query_id, 'query_id': query_id,
'execution_query': execution_query, 'execution_query': execution_query,
'reply_streams': reply_streams,
'plugin_identity': plugin_identity, 'plugin_identity': plugin_identity,
'authorization': authorization, 'authorization': authorization,
'status': { 'status': {
@@ -3,11 +3,20 @@ from sqlalchemy.exc import IntegrityError
from ....authz import Permission, has_permission from ....authz import Permission, has_permission
from ....context import RequestContext from ....context import RequestContext
from ....service.bot_errors import BotApplyError, bot_error_message
from ... import group from ... import group
@group.group_class('bots', '/api/v1/platform/bots') @group.group_class('bots', '/api/v1/platform/bots')
class BotsRouterGroup(group.RouterGroup): class BotsRouterGroup(group.RouterGroup):
def _apply_error_response(self, exc: BotApplyError):
request_id = self.request_id()
logger = getattr(self.ap, 'logger', self.quart_app.logger)
logger.error(f'Bot configuration apply failed request_id={request_id} bot_uuid={exc.bot_uuid}', exc_info=True)
return quart.jsonify(
code='bot_apply_failed', msg=str(exc), data={'uuid': exc.bot_uuid}, request_id=request_id
), 400
async def initialize(self) -> None: async def initialize(self) -> None:
@self.route( @self.route(
'', '',
@@ -34,7 +43,14 @@ class BotsRouterGroup(group.RouterGroup):
) )
async def _(request_context: RequestContext) -> str: async def _(request_context: RequestContext) -> str:
json_data = await quart.request.json json_data = await quart.request.json
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data) if not isinstance(json_data, dict):
return self.http_status(400, 'invalid_bot_config', 'Bot configuration must be an object')
try:
bot_uuid = await self.ap.bot_service.create_bot(request_context, json_data)
except BotApplyError as exc:
return self._apply_error_response(exc)
except ValueError as exc:
return self.http_status(400, 'invalid_bot_config', bot_error_message(exc, json_data))
return self.success(data={'uuid': bot_uuid}) return self.success(data={'uuid': bot_uuid})
@self.route( @self.route(
@@ -63,7 +79,14 @@ class BotsRouterGroup(group.RouterGroup):
async def _(bot_uuid: str, request_context: RequestContext) -> str: async def _(bot_uuid: str, request_context: RequestContext) -> str:
if quart.request.method == 'PUT': if quart.request.method == 'PUT':
json_data = await quart.request.json json_data = await quart.request.json
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data) if not isinstance(json_data, dict):
return self.http_status(400, 'invalid_bot_config', 'Bot configuration must be an object')
try:
await self.ap.bot_service.update_bot(request_context, bot_uuid, json_data)
except BotApplyError as exc:
return self._apply_error_response(exc)
except ValueError as exc:
return self.http_status(400, 'invalid_bot_config', bot_error_message(exc, json_data))
else: else:
await self.ap.bot_service.delete_bot(request_context, bot_uuid) await self.ap.bot_service.delete_bot(request_context, bot_uuid)
return self.success() return self.success()
+14 -10
View File
@@ -11,6 +11,7 @@ from ....entity.persistence import agent as persistence_agent
from ....entity.persistence import bot as persistence_bot from ....entity.persistence import bot as persistence_bot
from ....entity.persistence import pipeline as persistence_pipeline from ....entity.persistence import pipeline as persistence_pipeline
from ....workspace.errors import WorkspaceNotFoundError from ....workspace.errors import WorkspaceNotFoundError
from .bot_errors import BotApplyError, bot_error_message
from .tenant import TenantContext, require_workspace_uuid, scope_statement from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient from ....utils import httpclient
from ....platform.sources import http_bot_signing from ....platform.sources import http_bot_signing
@@ -664,7 +665,10 @@ class BotService:
bot = await self.get_bot(context, bot_data['uuid'], include_secret=True) bot = await self.get_bot(context, bot_data['uuid'], include_secret=True)
await self.ap.platform_mgr.load_bot(context, bot) try:
await self.ap.platform_mgr.load_bot(context, bot)
except Exception as exc:
raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc
return bot_data['uuid'] return bot_data['uuid']
@@ -694,17 +698,17 @@ class BotService:
runtime_bot.bot_entity.description = update_data['description'] runtime_bot.bot_entity.description = update_data['description']
return return
await self.ap.platform_mgr.remove_bot(context, bot_uuid) # Persisted configuration is distinct from applying it to the running adapter.
# select from db
bot = await self.get_bot(context, bot_uuid, include_secret=True) bot = await self.get_bot(context, bot_uuid, include_secret=True)
try:
await self.ap.platform_mgr.remove_bot(context, bot_uuid)
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot)
if runtime_bot.enable:
await runtime_bot.run()
except Exception as exc:
raise BotApplyError(bot_error_message(exc, bot), bot['uuid']) from exc
runtime_bot = await self.ap.platform_mgr.load_bot(context, bot) # Reset conversations using this bot after its configuration is applied.
if runtime_bot.enable:
await runtime_bot.run()
# update all conversation that use this bot
for session in self.ap.sess_mgr.session_list: for session in self.ap.sess_mgr.session_list:
if ( if (
session.using_conversation is not None session.using_conversation is not None
@@ -0,0 +1,43 @@
"""User-facing bot configuration errors without credentials or validation inputs."""
import json
import pydantic
from .secrets import redact_secrets
class BotApplyError(Exception):
"""Configuration was persisted, but the runtime could not apply it."""
def __init__(self, message: str, bot_uuid: str | None = None):
super().__init__(message)
self.bot_uuid = bot_uuid
def bot_error_message(error: Exception, configuration: dict) -> str:
if isinstance(error, pydantic.ValidationError):
text = '; '.join(
f'{".".join(map(str, item["loc"]))}: {item["msg"]}'
for item in error.errors(include_input=False, include_context=False, include_url=False)
)
else:
text = str(error).strip() or type(error).__name__
replacements = []
def collect(original, masked):
if isinstance(original, dict) and isinstance(masked, dict):
for key, value in original.items():
collect(value, masked.get(key))
elif isinstance(original, (list, tuple)) and isinstance(masked, (list, tuple)):
for value, replacement in zip(original, masked):
collect(value, replacement)
elif isinstance(original, str) and original and original != masked:
for representation in {original, repr(original)[1:-1], json.dumps(original, ensure_ascii=False)[1:-1]}:
replacements.append((representation, str(masked)))
collect(configuration, redact_secrets(configuration))
for original, masked in sorted(replacements, key=lambda item: len(item[0]), reverse=True):
text = text.replace(original, masked)
return text[:2000]
@@ -103,9 +103,9 @@ spec:
zh_Hans: 启用钉钉卡片流式回复模式 zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式 zh_Hant: 啟用釘釘卡片串流回覆模式
description: description:
en_US: If enabled, the bot will use DingTalk card streaming replies. en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use DingTalk card streaming replies.
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -38,6 +38,10 @@ spec:
en_US: Enable stream reply en_US: Enable stream reply
zh_Hans: 启用流式回复 zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆 zh_Hant: 啟用串流回覆
description:
en_US: Used for streaming replies from pipelines or plugins.
zh_Hans: 用于流水线和插件主动发起的流式回复。
zh_Hant: 用於流水線和插件主動發起的串流回覆。
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -174,10 +174,10 @@ spec:
zh_Hant: 啟用飛書串流回覆模式 zh_Hant: 啟用飛書串流回覆模式
ja_JP: ストリーミング返信モードを有効化 ja_JP: ストリーミング返信モードを有効化
description: description:
en_US: If enabled, replies are rendered through an updating Lark card. en_US: Used for streaming replies from pipelines or plugins. If enabled, replies are rendered through an updating Lark card.
zh_Hans: 如果启用,将使用可更新的飞书卡片进行流式回复。 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用可更新的飞书卡片进行流式回复。
zh_Hant: 如果啟用,將使用可更新的飛書卡片進行串流回覆。 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用可更新的飛書卡片進行串流回覆。
ja_JP: 有効にすると、更新可能な Lark カードでストリーミング返信します。 ja_JP: パイプラインやプラグインのストリーミング返信に適用されます。有効にすると、更新可能な Lark カードでストリーミング返信します。
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -100,9 +100,9 @@ spec:
zh_Hans: 启用流式回复模式 zh_Hans: 启用流式回复模式
zh_Hant: 啟用串流回覆模式 zh_Hant: 啟用串流回覆模式
description: description:
en_US: If enabled, the adapter uses QQ Official streaming replies for C2C private messages. en_US: Used for streaming replies from pipelines or plugins. If enabled, the adapter uses QQ Official streaming replies for C2C private messages.
zh_Hans: 启用后,适配器会对 C2C 私聊使用 QQ 官方流式回复。 zh_Hans: 用于流水线和插件主动发起的流式回复。启用后,适配器会对 C2C 私聊使用 QQ 官方流式回复。
zh_Hant: 啟用後,適配器會對 C2C 私聊使用 QQ 官方串流回覆。 zh_Hant: 用於流水線和插件主動發起的串流回覆。啟用後,適配器會對 C2C 私聊使用 QQ 官方串流回覆。
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -39,8 +39,8 @@ spec:
en_US: Enable Stream Reply Mode en_US: Enable Stream Reply Mode
zh_Hans: 启用电报流式回复模式 zh_Hans: 启用电报流式回复模式
description: description:
en_US: If enabled, the bot will use the stream of telegram reply mode en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of telegram reply mode
zh_Hans: 如果启用,将使用电报流式方式来回复内容 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用电报流式方式来回复内容
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -145,9 +145,9 @@ spec:
zh_Hans: 启用流式回复 zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆 zh_Hant: 啟用串流回覆
description: description:
en_US: If enabled, the bot will use WeComBot streaming replies. en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use WeComBot streaming replies.
zh_Hans: 如果启用,机器人将使用企业微信智能机器人流式回复。 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,机器人将使用企业微信智能机器人流式回复。
zh_Hant: 如果啟用,機器人將使用企業微信智慧機器人串流回覆。 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,機器人將使用企業微信智慧機器人串流回覆。
type: boolean type: boolean
required: false required: false
default: true default: true
+8 -83
View File
@@ -38,7 +38,6 @@ from .logger import EventLogger
from .adapter_names import canonical_adapter_name from .adapter_names import canonical_adapter_name
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.events as plugin_events import langbot_plugin.api.entities.events as plugin_events
import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
@@ -826,7 +825,7 @@ class RuntimeBot:
state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']),
delivery_policy=DeliveryPolicy( delivery_policy=DeliveryPolicy(
enable_streaming=False, enable_streaming=False,
enable_reply=True, enable_reply=False,
enable_interactions=agent.get('kind') != 'event_processor', enable_interactions=agent.get('kind') != 'event_processor',
), ),
agent_id=agent.get('uuid'), agent_id=agent.get('uuid'),
@@ -834,65 +833,6 @@ class RuntimeBot:
processor_id=agent.get('uuid'), processor_id=agent.get('uuid'),
) )
@staticmethod
def _provider_content_to_text(content: typing.Any) -> str:
if content is None:
return ''
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
item_data = item.model_dump(mode='json') if hasattr(item, 'model_dump') else item
if isinstance(item_data, dict):
if item_data.get('type') == 'text' and item_data.get('text') is not None:
parts.append(str(item_data.get('text')))
elif item_data.get('text') is not None:
parts.append(str(item_data.get('text')))
elif item_data is not None:
parts.append(str(item_data))
return ''.join(parts)
return str(content)
@classmethod
def _provider_output_to_text(cls, result: provider_message.Message | provider_message.MessageChunk) -> str:
if getattr(result, 'all_content', None):
return str(getattr(result, 'all_content'))
return cls._provider_content_to_text(getattr(result, 'content', None))
async def _deliver_agent_outputs(
self,
envelope: AgentEventEnvelope,
outputs: list[provider_message.Message | provider_message.MessageChunk],
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter | None = None,
) -> None:
if not outputs or not envelope.delivery.reply_target:
return
reply_target = envelope.delivery.reply_target
target_type = reply_target.get('target_type')
target_id = reply_target.get('target_id')
if not target_type or not target_id:
return
final_text = ''
for output in outputs:
output_text = self._provider_output_to_text(output)
if isinstance(output, provider_message.Message):
final_text = output_text or final_text
elif output_text:
final_text = output_text
if not final_text:
return
delivery_adapter = adapter or self.adapter
await delivery_adapter.send_message(
str(target_type),
str(target_id),
platform_message.MessageChain([platform_message.Plain(text=final_text)]),
)
async def _handle_platform_event( async def _handle_platform_event(
self, self,
event: platform_events.EBAEvent, event: platform_events.EBAEvent,
@@ -1053,17 +993,18 @@ class RuntimeBot:
envelope = self._eba_event_to_agent_envelope(event, adapter) envelope = self._eba_event_to_agent_envelope(event, adapter)
if target_type == 'event_processor': if target_type == 'event_processor':
envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'}) envelope.data = event.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
outputs: list[provider_message.Message | provider_message.MessageChunk] = []
try: try:
async for output in self.ap.agent_run_orchestrator.run( async for _ in self.ap.agent_run_orchestrator.run(
envelope, envelope,
binding, binding,
adapter_context={ adapter_context={
'_delivery_adapter': adapter, '_delivery_adapter': adapter,
'_platform_event': event,
'_execution_context': self.execution_context, '_execution_context': self.execution_context,
}, },
): ):
outputs.append(output) # Results are journaled by the orchestrator; platform sends require explicit actions.
pass
except Exception: except Exception:
return await self._record_event_route_trace( return await self._record_event_route_trace(
event_type=event_type, event_type=event_type,
@@ -1076,21 +1017,6 @@ class RuntimeBot:
reason='Agent runner failed', reason='Agent runner failed',
text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}', text=f'Failed to run Agent for EBA event {event_type}: {traceback.format_exc()}',
) )
try:
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter)
except Exception:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='error',
binding=event_binding,
target_type=target_type,
target_uuid=target_uuid,
failure_code='delivery_failed',
reason='Agent output delivery failed',
text=f'Failed to deliver Agent output for EBA event {event_type}: {traceback.format_exc()}',
)
return await self._record_event_route_trace( return await self._record_event_route_trace(
event_type=event_type, event_type=event_type,
status='delivered', status='delivered',
@@ -1458,14 +1384,13 @@ class RuntimeBot:
data={'interaction': submission}, data={'interaction': submission},
) )
outputs: list[provider_message.Message | provider_message.MessageChunk] = [] async for _ in self.ap.agent_run_orchestrator.run(
async for output in self.ap.agent_run_orchestrator.run(
envelope, envelope,
binding, binding,
adapter_context={'_delivery_adapter': adapter}, adapter_context={'_delivery_adapter': adapter},
): ):
outputs.append(output) # Resuming an interaction follows the same explicit-action delivery policy.
await self._deliver_agent_outputs(envelope, outputs, adapter=adapter) pass
async def _dispatch_eba_message_to_pipeline( async def _dispatch_eba_message_to_pipeline(
self, self,
@@ -82,9 +82,9 @@ spec:
zh_Hans: 启用钉钉卡片流式回复模式 zh_Hans: 启用钉钉卡片流式回复模式
zh_Hant: 啟用釘釘卡片串流回覆模式 zh_Hant: 啟用釘釘卡片串流回覆模式
description: description:
en_US: If enabled, the bot will use the stream of lark reply mode en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用钉钉卡片流式方式来回复内容
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用釘釘卡片串流方式來回覆內容
type: boolean type: boolean
required: true required: true
default: false default: false
+4 -4
View File
@@ -178,10 +178,10 @@ spec:
zh_Hant: 啟用飛書串流回覆模式 zh_Hant: 啟用飛書串流回覆模式
ja_JP: ストリーミング返信モードを有効化 ja_JP: ストリーミング返信モードを有効化
description: description:
en_US: If enabled, the bot will use the stream of lark reply mode en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of lark reply mode
zh_Hans: 如果启用,将使用飞书流式方式来回复内容 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用飞书流式方式来回复内容
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用飛書串流方式來回覆內容
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します ja_JP: パイプラインやプラグインのストリーミング返信に適用されます。有効にすると、ボットはストリーミングモードでメッセージに返信します
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -64,8 +64,8 @@ spec:
vi_VN: Bật phản hồi luồng vi_VN: Bật phản hồi luồng
es_ES: Activar respuesta en streaming es_ES: Activar respuesta en streaming
description: description:
en_US: Update a Mattermost post while LangBot generates a response en_US: Used for streaming replies from pipelines or plugins. Update a Mattermost post while LangBot generates a response
zh_Hans: 在 LangBot 生成回复时持续更新同一条 Mattermost 消息 zh_Hans: 用于流水线和插件主动发起的流式回复。在 LangBot 生成回复时持续更新同一条 Mattermost 消息
type: boolean type: boolean
required: false required: false
default: true default: true
@@ -90,9 +90,9 @@ spec:
zh_Hans: 启用流式回复模式 zh_Hans: 启用流式回复模式
zh_Hant: 啟用串流回覆模式 zh_Hant: 啟用串流回覆模式
description: description:
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only) en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use streaming mode to reply messages (C2C only)
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊) zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,机器人将使用流式方式回复消息(仅私聊)
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊) zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -63,13 +63,13 @@ spec:
vi_VN: Bật chế độ trả lời trực tuyến vi_VN: Bật chế độ trả lời trực tuyến
es_ES: Habilitar modo de respuesta en streaming es_ES: Habilitar modo de respuesta en streaming
description: description:
en_US: If enabled, the bot will use the stream of telegram reply mode en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use the stream of telegram reply mode
zh_Hans: 如果启用,将使用电报流式方式来回复内容 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,将使用电报流式方式来回复内容
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,將使用 Telegram 串流方式來回覆內容
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します ja_JP: パイプラインやプラグインのストリーミング返信に適用されます。有効にすると、ボットはストリーミングモードでメッセージに返信します
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ th_TH: ใช้กับการตอบกลับแบบสตรีมจากไปป์ไลน์หรือปลั๊กอิน หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram vi_VN: Áp dụng cho phản hồi streaming từ pipeline hoặc plugin. Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram es_ES: Se aplica a respuestas en streaming de pipelines o plugins. Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
type: boolean type: boolean
required: true required: true
default: false default: false
@@ -142,9 +142,9 @@ spec:
zh_Hans: 启用流式回复 zh_Hans: 启用流式回复
zh_Hant: 啟用串流回覆 zh_Hant: 啟用串流回覆
description: description:
en_US: If enabled, the bot will use streaming mode to reply messages en_US: Used for streaming replies from pipelines or plugins. If enabled, the bot will use streaming mode to reply messages
zh_Hans: 如果启用,机器人将使用流式模式回复消息 zh_Hans: 用于流水线和插件主动发起的流式回复。如果启用,机器人将使用流式模式回复消息
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息 zh_Hant: 用於流水線和插件主動發起的串流回覆。如果啟用,機器人將使用串流模式回覆訊息
type: boolean type: boolean
required: false required: false
default: true default: true
+50
View File
@@ -1511,6 +1511,56 @@ class RuntimeConnectionHandler(handler.Handler):
}, },
) )
async def reply_stream(data: dict[str, Any]) -> handler.ActionResponse:
"""Explicit reply delivery authorized by the frozen event_reply permission."""
from ..agent.runner.reply_stream import ReplyStreamRequest
action_context = self._require_runtime_action_context()
session, error = await _validate_run_authorization(
data.get('run_id'),
'tool',
'event_reply',
self.ap,
data.get('caller_plugin_identity'),
operation='call',
)
if error:
return error
source_ref, error = _validate_frozen_tool_source_identity(session, 'event_reply', self.ap)
if error:
return error
if source_ref is None or source_ref['source'] != 'platform':
return handler.ActionResponse.error('event_reply must be a Host platform tool')
query = session.get('execution_query')
context = self._execution_context(action_context)
if query is None or any(
getattr(query, field, None) != getattr(context, field, None)
for field in ('instance_uuid', 'workspace_uuid', 'placement_generation')
):
return handler.ActionResponse.error('Reply stream run belongs to another execution scope')
streams = session.get('reply_streams')
if streams is None:
return handler.ActionResponse.error('Streaming replies are unavailable for this run')
try:
if not streams.mock:
bot_id = session['authorization'].get('bot_id')
bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_id)
if bot is None or bot.adapter is not streams.adapter:
return handler.ActionResponse.error('The reply adapter is no longer active')
if 'send_message' not in bot.adapter.get_supported_apis():
return handler.ActionResponse.error('The reply adapter no longer supports sending messages')
request = ReplyStreamRequest.model_validate(
{key: data[key] for key in ('stream_id', 'operation', 'text') if key in data}
)
result = await streams.apply(request)
return handler.ActionResponse.success(data={'result': result})
except Exception as exc:
return handler.ActionResponse.error(f'Streaming reply failed: {exc}')
reply_stream_action = getattr(PluginToRuntimeAction, 'REPLY_STREAM', None)
if reply_stream_action is not None:
self.action(reply_stream_action)(reply_stream)
@self.action(PluginToRuntimeAction.CALL_TOOL) @self.action(PluginToRuntimeAction.CALL_TOOL)
async def call_tool(data: dict[str, Any]) -> handler.ActionResponse: async def call_tool(data: dict[str, Any]) -> handler.ActionResponse:
"""Call a tool """Call a tool
+270
View File
@@ -0,0 +1,270 @@
"""Explicit streaming delivery across SDK, Host lifecycle, and adapter boundaries."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import pytest
from langbot_plugin.api.entities.builtin.platform import events, entities, message
from langbot_plugin.api.entities.builtin.agent_runner.context_access import ContextAPICapabilities
from langbot_plugin.api.proxies.agent_run import AgentRunAPIProxy
from langbot_plugin.api.proxies.agent_run.common import PermissionDeniedError
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
from langbot.pkg.agent.runner.reply_stream import ReplyStreamRequest, ReplyStreamSession
def make_session(*, native=True, source=True, mock=False):
event = SimpleNamespace(
delivery=SimpleNamespace(
surface='webui' if mock else 'platform',
platform_capabilities={'debug_mock': mock},
reply_target={'target_type': 'person', 'target_id': 'user-1'},
)
)
incoming = (
events.MessageReceivedEvent(
message_id='source-1',
sender=entities.User(id='user-1'),
chat_id='user-1',
chat_type=entities.ChatType.PRIVATE,
message_chain=message.MessageChain([message.Plain(text='hello')]),
source_platform_object=object(),
)
if source
else None
)
adapter = SimpleNamespace(
is_stream_output_supported=AsyncMock(return_value=native),
create_message_card=AsyncMock(return_value=True),
reply_message_chunk=AsyncMock(),
send_message=AsyncMock(),
reply_message=AsyncMock(),
)
return ReplyStreamSession(event, adapter, incoming), adapter, incoming
def request(key, operation='update', text='hello'):
return ReplyStreamRequest(stream_id=key, operation=operation, text=text)
def proxy_for(session, *, allowed=True, advertised=True):
if not hasattr(AgentRunAPIProxy, 'reply_stream'):
pytest.skip('SDK does not provide the optional streaming reply API')
context = SimpleNamespace(
run_id='run-1',
runtime=SimpleNamespace(deadline_at=None),
context=SimpleNamespace(available_apis=ContextAPICapabilities(reply_stream=advertised)),
resources=SimpleNamespace(
models=[],
knowledge_bases=[],
tools=[SimpleNamespace(tool_name='event_reply', operations=['call'])] if allowed else [],
),
)
async def action(action, data, timeout):
assert action == PluginToRuntimeAction.REPLY_STREAM
assert data['run_id'] == 'run-1'
return {
'result': await session.apply(
ReplyStreamRequest.model_validate({k: v for k, v in data.items() if k != 'run_id'})
)
}
transport = SimpleNamespace(call_action=AsyncMock(side_effect=action))
return AgentRunAPIProxy(context, transport), transport
@pytest.mark.parametrize(
'native,source,mock',
[
(True, True, False),
(False, True, False),
(True, False, False),
(True, True, True),
],
)
async def test_sdk_stream_reuses_adapter_or_sends_one_final_message(native, source, mock):
session, adapter, incoming = make_session(native=native, source=source, mock=mock)
api, transport = proxy_for(session)
async with api.reply_stream() as stream:
await stream.update('hello')
await stream.update('hello world')
adapter.send_message.assert_not_awaited()
adapter.reply_message.assert_not_awaited()
assert stream.result['status'] == 'completed'
assert stream.result['text'] == 'hello world'
assert transport.call_action.await_count == 3
if mock:
adapter.create_message_card.assert_not_awaited()
adapter.reply_message_chunk.assert_not_awaited()
adapter.send_message.assert_not_awaited()
assert stream.result['mock'] is True
elif native and source:
adapter.create_message_card.assert_awaited_once()
delivered_source = adapter.create_message_card.await_args.args[1]
assert delivered_source.source_platform_object is incoming.source_platform_object
chunks = adapter.reply_message_chunk.await_args_list
assert [c.kwargs['bot_message'].all_content for c in chunks] == ['hello', 'hello world', 'hello world']
assert [c.kwargs['is_final'] for c in chunks] == [False, False, True]
assert chunks[-1].kwargs['bot_message'].tool_calls is None
else:
adapter.reply_message_chunk.assert_not_awaited()
if source:
adapter.reply_message.assert_awaited_once()
assert adapter.reply_message.await_args.kwargs['message'][0].text == 'hello world'
else:
adapter.send_message.assert_awaited_once()
assert adapter.send_message.await_args.args[2][0].text == 'hello world'
await session.close()
@pytest.mark.parametrize('native', [True, False])
@pytest.mark.parametrize('error', [RuntimeError, asyncio.CancelledError])
async def test_exception_finalizes_visible_card_without_sending_buffered_partial(native, error):
session, adapter, _ = make_session(native=native)
api, _ = proxy_for(session)
with pytest.raises(error):
async with api.reply_stream() as stream:
await stream.update('partial')
raise error()
if native:
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
adapter.reply_message.assert_not_awaited()
adapter.send_message.assert_not_awaited()
count = adapter.reply_message_chunk.await_count
await session.close()
assert adapter.reply_message_chunk.await_count == count
@pytest.mark.parametrize('allowed,advertised', [(False, True), (True, False)])
async def test_missing_permission_or_old_host_fails_before_delivery(allowed, advertised):
session, _, _ = make_session()
api, transport = proxy_for(session, allowed=allowed, advertised=advertised)
with pytest.raises(PermissionDeniedError):
async with api.reply_stream():
pytest.fail('Not authorized')
transport.call_action.assert_not_awaited()
async def test_empty_stream_and_duplicate_finish_do_not_send_twice():
session, adapter, _ = make_session(native=False)
empty = uuid4()
await session.apply(request(empty, 'finish', ''))
adapter.reply_message.assert_not_awaited()
key = uuid4()
await session.apply(request(key))
first = await session.apply(request(key, 'finish'))
assert await session.apply(request(key, 'finish')) == first
adapter.reply_message.assert_awaited_once()
with pytest.raises(ValueError, match='closed'):
await session.apply(request(key))
await session.close()
with pytest.raises(ValueError, match='ended'):
await session.apply(request(uuid4()))
async def test_host_cleanup_closes_stream_when_plugin_disappears():
session, adapter, _ = make_session()
await session.apply(request(uuid4()))
await session.close()
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
async def test_uncertain_final_send_is_not_retried_by_finish_or_cleanup():
session, adapter, _ = make_session(native=False)
adapter.reply_message.side_effect = TimeoutError('Response lost')
key = uuid4()
with pytest.raises(TimeoutError):
await session.apply(request(key, 'finish'))
with pytest.raises(ValueError, match='closed'):
await session.apply(request(key, 'finish'))
await session.close()
adapter.reply_message.assert_awaited_once()
async def test_failed_update_closes_existing_card_during_run_cleanup():
session, adapter, _ = make_session()
adapter.reply_message_chunk.side_effect = [RuntimeError('update failed'), None]
with pytest.raises(RuntimeError):
await session.apply(request(uuid4()))
await session.close()
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
async def test_streams_are_isolated_by_run_and_bounded():
first, a, _ = make_session(native=False)
second, b, _ = make_session(native=False)
key = uuid4()
await first.apply(request(key, 'update', 'first'))
await second.apply(request(key, 'finish', 'second'))
assert b.reply_message.await_args.kwargs['message'][0].text == 'second'
a.reply_message.assert_not_awaited()
for _ in range(15):
await first.apply(request(uuid4(), 'finish', ''))
with pytest.raises(ValueError, match='at most'):
await first.apply(request(uuid4()))
await first.close()
a.reply_message.assert_not_awaited()
async def test_event_processor_uses_shared_sdk_api_and_emits_one_trace_for_the_stream():
from unittest.mock import Mock
from langbot_plugin.api.definition.components.event_processor import EventProcessor
session, adapter, incoming = make_session()
api, _ = proxy_for(session)
processor = EventProcessor()
processor.get_run_api = Mock(return_value=api)
@processor.handler(events.MessageReceivedEvent)
async def handle(ctx):
async with ctx.reply_stream() as stream:
await stream.update('one')
await stream.update('one two')
context = SimpleNamespace(
run_id='run-1',
config={},
event=SimpleNamespace(
data=incoming.model_dump(mode='json', exclude={'source_platform_object', 'legacy_event'})
),
)
results = [result async for result in processor.run(context)]
assert [r.type for r in results] == ['tool.call.started', 'tool.call.completed', 'run.completed']
assert results[1].data['result']['text'] == 'one two'
assert adapter.reply_message_chunk.await_count == 3
await session.close()
async def test_shared_adapter_uses_host_ids_to_isolate_identical_plugin_stream_ids():
first, adapter, _ = make_session()
second, _, _ = make_session()
second.adapter = adapter
key = uuid4()
await first.apply(request(key, text='first'))
await second.apply(request(key, text='second'))
ids = [c.args[0] for c in adapter.create_message_card.await_args_list]
assert len(set(ids)) == 2
assert str(key) not in ids
await first.close()
await second.close()
async def test_run_cleanup_cancels_inflight_update_and_finalizes_card():
session, adapter, _ = make_session()
started = asyncio.Event()
async def update(**kwargs):
if not kwargs['is_final']:
started.set()
await asyncio.Event().wait()
adapter.reply_message_chunk.side_effect = update
task = asyncio.create_task(session.apply(request(uuid4())))
await asyncio.wait_for(started.wait(), 1)
await asyncio.wait_for(session.close(), 1)
with pytest.raises(asyncio.CancelledError):
await task
assert adapter.reply_message_chunk.await_args.kwargs['is_final'] is True
@@ -151,7 +151,18 @@ class TestAgentServiceMetadata:
class TestAgentServiceDebug: class TestAgentServiceDebug:
@pytest.mark.parametrize('streaming', [False, True]) @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() app = _make_app()
agent_config = _agent_row().config agent_config = _agent_row().config
agent_config['allowed_platform_tools'] = ['platform_get_user_info'] agent_config['allowed_platform_tools'] = ['platform_get_user_info']
@@ -162,7 +173,7 @@ class TestAgentServiceDebug:
agent_config['allowed_tools'] = ['exec', 'weather'] agent_config['allowed_tools'] = ['exec', 'weather']
visible_event = { visible_event = {
'type': 'tool.call.started', 'type': result_type,
'data': {'tool_name': 'exec', 'parameters': {'command': 'echo hi'}}, 'data': {'tool_name': 'exec', 'parameters': {'command': 'echo hi'}},
} }
observer = AsyncMock() if streaming else None observer = AsyncMock() if streaming else None
@@ -171,6 +182,9 @@ class TestAgentServiceDebug:
assert binding.delivery_policy.enable_streaming is streaming 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']({**visible_event, 'private_context': 'must not leak'})
await adapter_context['_result_observer']({'type': 'state.updated', 'data': {'private': True}}) 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( yield SimpleNamespace(
role='assistant', role='assistant',
content='debug result', 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
@@ -447,7 +447,7 @@ class TestEBAEventBindings:
'event_get_actor', 'event_get_actor',
] ]
assert binding.delivery_policy.enable_streaming is False assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is True assert binding.delivery_policy.enable_reply is False
assert binding.delivery_policy.enable_interactions is True assert binding.delivery_policy.enable_interactions is True
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner'] assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
assert binding.agent_id == 'agent-1' assert binding.agent_id == 'agent-1'
@@ -651,7 +651,9 @@ class TestInteractionResumeRouting:
assert binding.processor_type == 'agent' assert binding.processor_type == 'agent'
assert binding.processor_id == 'agent-1' assert binding.processor_id == 'agent-1'
assert adapter_context == {'_delivery_adapter': adapter} assert adapter_context == {'_delivery_adapter': adapter}
adapter.send_message.assert_awaited_once() assert envelope.delivery.supports_streaming is False
assert binding.delivery_policy.enable_reply is False
adapter.send_message.assert_not_awaited()
def test_agent_product_to_binding_does_not_fallback_to_component_ref(self): def test_agent_product_to_binding_does_not_fallback_to_component_ref(self):
"""An empty config runner stays unconfigured even if component_ref is stale.""" """An empty config runner stays unconfigured even if component_ref is stale."""
@@ -762,3 +764,89 @@ async def test_bound_event_processor_receives_one_complete_typed_event():
assert envelope.data['type'] == 'group.member_joined' assert envelope.data['type'] == 'group.member_joined'
assert 'source_platform_object' not in envelope.data assert 'source_platform_object' not in envelope.data
bot.ap.plugin_connector.emit_event.assert_not_called() bot.ap.plugin_connector.emit_event.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize('kind', ['agent', 'event_processor'])
@pytest.mark.parametrize('output_kind', ['message', 'chunks', 'tool_rounds'])
@pytest.mark.parametrize('explicit_reply', [False, True])
@pytest.mark.parametrize('runner_fails', [False, True])
async def test_processor_outputs_require_explicit_platform_actions(kind, output_kind, explicit_reply, runner_fails):
"""Draining runner results must not send text, duplicate replies, or stream cards."""
from langbot_plugin.api.entities.builtin.platform import entities, events, message
from langbot_plugin.api.entities.builtin.provider import message as provider_message
from langbot.pkg.agent.runner.platform_tools import execute_platform_tool, freeze_platform_context
bot = TestEventRouteTrace._make_bot(
[{'id': 'binding-1', 'event_pattern': 'message.received', 'target_type': kind, 'target_uuid': 'agent-1'}]
)
adapter = SimpleNamespace(
get_supported_apis=lambda: ['send_message'],
is_stream_output_supported=AsyncMock(return_value=True),
send_message=AsyncMock(return_value='message-2'),
create_message_card=AsyncMock(),
reply_message_chunk=AsyncMock(),
)
completed = []
async def run(envelope, binding, adapter_context):
assert envelope.delivery.supports_streaming is False
assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is False
assert adapter_context['_delivery_adapter'] is adapter
if explicit_reply:
session = {'authorization': {'bot_id': 'bot-1', 'platform_context': freeze_platform_context(envelope)}}
await execute_platform_tool(bot.ap, TEST_CONTEXT, session, 'event_reply', {'text': 'Working on it'})
# Progress arrives while the runner is still working.
adapter.send_message.assert_awaited_once()
assert completed == []
if output_kind == 'chunks':
yield provider_message.MessageChunk(role='assistant', content='Done', all_content='Done')
yield provider_message.MessageChunk(role='assistant', content='.', all_content='Done.', is_final=True)
elif output_kind == 'tool_rounds':
yield provider_message.Message(role='assistant', content='Checking the request')
yield provider_message.Message(role='assistant', content='Done.')
else:
yield provider_message.Message(role='assistant', content='Done.')
if runner_fails:
raise RuntimeError('Runner failed after producing text')
completed.append(True)
bot.ap = SimpleNamespace(
workspace_service=active_workspace_service(),
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter))),
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'uuid': 'agent-1',
'kind': kind,
'enabled': True,
'supported_event_patterns': ['message.received'],
'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}},
}
)
),
agent_run_orchestrator=SimpleNamespace(run=run),
)
event = events.MessageReceivedEvent(
message_id='message-1',
message_chain=message.MessageChain([message.Plain(text='hello')]),
sender=entities.User(id='user-1', nickname='QA'),
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
)
trace = await bot._dispatch_eba_event_to_processor(event, adapter)
assert trace['status'] == ('failed' if runner_fails else 'delivered')
if runner_fails:
assert trace['failure_code'] == 'runner_failed'
assert completed == ([] if runner_fails else [True])
assert adapter.send_message.await_count == int(explicit_reply)
if explicit_reply:
kwargs = adapter.send_message.await_args.kwargs
assert kwargs['target_type'] == 'person'
assert kwargs['target_id'] == 'user-1'
assert kwargs['message'][0].text == 'Working on it'
adapter.create_message_card.assert_not_awaited()
adapter.reply_message_chunk.assert_not_awaited()
@@ -1575,9 +1575,7 @@ class TestAgentRunProxyActions:
query = build_execution_query(event, []) query = build_execution_query(event, [])
app.box_service = SimpleNamespace( app.box_service = SimpleNamespace(
available=True, available=True,
get_backend_status=AsyncMock( get_backend_status=AsyncMock(return_value={'backend': {'available': True}}),
return_value={'backend': {'available': True}}
),
execute_tool=AsyncMock( execute_tool=AsyncMock(
return_value={ return_value={
'ok': True, 'ok': True,
@@ -1683,3 +1681,88 @@ class TestAgentRunProxyActions:
provider.invoke_rerank.assert_awaited_once() provider.invoke_rerank.assert_awaited_once()
kwargs = provider.invoke_rerank.await_args.kwargs kwargs = provider.invoke_rerank.await_args.kwargs
assert kwargs['extra_args'] == {'top_n': 2, 'return_documents': False} assert kwargs['extra_args'] == {'top_n': 2, 'return_documents': False}
@pytest.mark.parametrize(
'case',
[
'valid',
'expired',
'plugin',
'workspace',
'permission',
'shadowed',
'native',
'bot_removed',
'adapter_replaced',
'api_revoked',
],
)
@pytest.mark.skipif(not hasattr(PluginToRuntimeAction, 'REPLY_STREAM'), reason='SDK does not support streaming replies')
async def test_reply_stream_authorization_is_run_and_workspace_scoped(case):
from uuid import uuid4
from langbot.pkg.agent.runner.session_registry import get_session_registry
app = SimpleNamespace(logger=Mock(), _test_plugin_identity='test/runner')
runtime_handler = make_handler(app)
query = SimpleNamespace(
**{
field: getattr(TEST_EXECUTION_CONTEXT, field)
for field in ('instance_uuid', 'workspace_uuid', 'placement_generation')
}
)
if case == 'workspace':
query.workspace_uuid = 'another-workspace'
streams = SimpleNamespace(mock=True, apply=AsyncMock(return_value={'status': 'completed'}))
if case in {'native', 'bot_removed', 'adapter_replaced', 'api_revoked'}:
streams.mock = False
streams.adapter = SimpleNamespace(get_supported_apis=lambda: [] if case == 'api_revoked' else ['send_message'])
bot = (
None
if case == 'bot_removed'
else SimpleNamespace(adapter=object() if case == 'adapter_replaced' else streams.adapter)
)
app.platform_mgr = SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=bot))
registry = get_session_registry()
run_id = str(uuid4())
resources = make_agent_resources(
tools=[]
if case == 'permission'
else [
{
'tool_name': 'event_reply',
'operations': ['call'],
'source': 'mcp' if case == 'shadowed' else 'platform',
'source_id': 'event_reply',
}
]
)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='other/plugin' if case == 'plugin' else 'test/runner',
resources=resources,
execution_query=query,
reply_streams=streams,
)
try:
if case == 'expired':
await registry.unregister(run_id)
response = await runtime_handler.actions[PluginToRuntimeAction.REPLY_STREAM.value](
{
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'stream_id': str(uuid4()),
'operation': 'finish',
'text': 'hello',
}
)
if case in {'valid', 'native'}:
assert response.code == 0
streams.apply.assert_awaited_once()
else:
assert response.code != 0
streams.apply.assert_not_awaited()
finally:
await registry.unregister(run_id)
Generated
+2 -2
View File
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=f82b3ce935f9a33afee389fb39fe8dc29a45b615" }, { name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=ca0671b81db5b2ed937d01158a3982de3fd8500f" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2186,7 +2186,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.5" version = "0.5.5"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=f82b3ce935f9a33afee389fb39fe8dc29a45b615#f82b3ce935f9a33afee389fb39fe8dc29a45b615" } source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=ca0671b81db5b2ed937d01158a3982de3fd8500f#ca0671b81db5b2ed937d01158a3982de3fd8500f" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
+4 -7
View File
@@ -24,6 +24,7 @@ import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react'; import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { showBotError } from './bot-error';
import { useCurrentWorkspace } from '@/app/infra/http'; import { useCurrentWorkspace } from '@/app/infra/http';
import { Bot } from '@/app/infra/entities/api'; import { Bot } from '@/app/infra/entities/api';
import EntityBasicInfoDialog, { import EntityBasicInfoDialog, {
@@ -91,9 +92,9 @@ export default function BotDetailContent({ id }: { id: string }) {
current ? { ...current, enable: checked } : current, current ? { ...current, enable: checked } : current,
); );
refreshBots(); refreshBots();
} catch { } catch (error) {
setBotEnabled(prev); setBotEnabled(prev);
toast.error(t('bots.setBotEnableError')); showBotError(error, t('bots.setBotEnableError'), t);
} }
}, },
[id, botEnabled, refreshBots, t], [id, botEnabled, refreshBots, t],
@@ -129,11 +130,7 @@ export default function BotDetailContent({ id }: { id: string }) {
await refreshBots(); await refreshBots();
toast.success(t('bots.saveSuccess')); toast.success(t('bots.saveSuccess'));
} catch (error) { } catch (error) {
const message = showBotError(error, t('bots.saveError'), t);
typeof error === 'object' && error && 'msg' in error
? String((error as { msg?: string }).msg || '')
: '';
toast.error(t('bots.saveError') + message);
throw error; throw error;
} }
} }
+31
View File
@@ -0,0 +1,31 @@
import type { TFunction } from 'i18next';
import { toast } from 'sonner';
export function showBotError(error: unknown, title: string, t: TFunction) {
const detail =
error && typeof error === 'object'
? (error as {
code?: string;
msg?: string;
message?: string;
request_id?: string;
})
: {};
const message = detail.msg || detail.message || '';
const description =
detail.code === 'internal_error'
? [
t('bots.internalErrorHint'),
detail.request_id &&
t('bots.errorReference', { id: detail.request_id }),
]
.filter(Boolean)
.join('\n')
: message;
toast.error(
detail.code === 'bot_apply_failed'
? t('bots.applyFailed')
: title.replace(/[:]\s*$/, ''),
{ description, duration: 10000 },
);
}
@@ -1,3 +1,4 @@
import { showBotError } from '../../bot-error';
import React, { import React, {
forwardRef, forwardRef,
useEffect, useEffect,
@@ -413,7 +414,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
toast.success(t('bots.saveSuccess')); toast.success(t('bots.saveSuccess'));
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.saveError') + err.msg); showBotError(err, t('bots.saveError'), t);
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
@@ -438,7 +439,10 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
onNewBotCreated(res.uuid); onNewBotCreated(res.uuid);
}) })
.catch((err) => { .catch((err) => {
toast.error(t('bots.createError') + err.msg); showBotError(err, t('bots.createError'), t);
if (err.code === 'bot_apply_failed' && err.data?.uuid) {
onNewBotCreated(err.data.uuid);
}
}) })
.finally(() => { .finally(() => {
setIsLoading(false); setIsLoading(false);
+3
View File
@@ -139,6 +139,9 @@ export abstract class BaseHttpClient {
code: data?.code || status, code: data?.code || status,
msg: errMsg, msg: errMsg,
data: data?.data || null, data: data?.data || null,
request_id:
(data as { request_id?: string })?.request_id ||
error.response.headers['x-request-id'],
}); });
} }
+5
View File
@@ -340,6 +340,11 @@ const enUS = {
}, },
}, },
bots: { bots: {
applyFailed: 'Configuration saved, but could not be applied',
internalErrorHint:
'An unexpected error occurred. Check the backend logs using the reference below.',
errorReference: 'Error reference: {{id}}',
title: 'Bots', title: 'Bots',
description: description:
'Create and manage bots, which are the entry points for LangBot to connect with various platforms', 'Create and manage bots, which are the entry points for LangBot to connect with various platforms',
+5
View File
@@ -348,6 +348,11 @@ const esES = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
applyFailed: 'Configuración guardada, pero no se pudo aplicar',
internalErrorHint:
'Se produjo un error interno. Consulta los registros del servidor con esta referencia.',
errorReference: 'Referencia del error: {{id}}',
adapterEventDebugAction: 'Probar escucha', adapterEventDebugAction: 'Probar escucha',
title: 'Bots', title: 'Bots',
description: description:
+5
View File
@@ -346,6 +346,11 @@ const jaJP = {
}, },
}, },
bots: { bots: {
applyFailed: '設定を保存しましたが、適用に失敗しました',
internalErrorHint:
'内部エラーが発生しました。エラー番号でバックエンドのログを確認してください。',
errorReference: 'エラー番号: {{id}}',
title: 'ボット', title: 'ボット',
description: description:
'ボットの作成と管理を行います。LangBotと各プラットフォームを接続するためのエントリーポイントです', 'ボットの作成と管理を行います。LangBotと各プラットフォームを接続するためのエントリーポイントです',
+5
View File
@@ -346,6 +346,11 @@ const ruRU = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
applyFailed: 'Настройки сохранены, но не применены',
internalErrorHint:
'Внутренняя ошибка. Проверьте журналы сервера по указанному идентификатору.',
errorReference: 'Идентификатор ошибки: {{id}}',
adapterEventDebugAction: 'Тест прослушивания', adapterEventDebugAction: 'Тест прослушивания',
title: 'Боты', title: 'Боты',
description: description:
+5
View File
@@ -333,6 +333,11 @@ const thTH = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
applyFailed: 'บันทึกการตั้งค่าแล้ว แต่ไม่สามารถนำไปใช้ได้',
internalErrorHint:
'เกิดข้อผิดพลาดภายใน โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์ด้วยหมายเลขอ้างอิง',
errorReference: 'หมายเลขข้อผิดพลาด: {{id}}',
adapterEventDebugAction: 'ทดสอบการรับเหตุการณ์', adapterEventDebugAction: 'ทดสอบการรับเหตุการณ์',
title: 'บอท', title: 'บอท',
description: description:
+5
View File
@@ -342,6 +342,11 @@ const viVN = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
applyFailed: 'Đã lưu cấu hình nhưng không thể áp dụng',
internalErrorHint:
'Đã xảy ra lỗi nội bộ. Hãy kiểm tra nhật ký máy chủ bằng mã lỗi.',
errorReference: 'Mã lỗi: {{id}}',
adapterEventDebugAction: 'Kiểm tra lắng nghe', adapterEventDebugAction: 'Kiểm tra lắng nghe',
title: 'Bot', title: 'Bot',
description: description:
+4
View File
@@ -325,6 +325,10 @@ const zhHans = {
}, },
}, },
bots: { bots: {
applyFailed: '配置已保存,但应用失败',
internalErrorHint: '发生内部错误,请通过错误编号查看后端日志。',
errorReference: '错误编号:{{id}}',
title: '机器人', title: '机器人',
description: '创建和管理机器人,这是 LangBot 与各个平台连接的入口', description: '创建和管理机器人,这是 LangBot 与各个平台连接的入口',
createBot: '创建机器人', createBot: '创建机器人',
+4
View File
@@ -322,6 +322,10 @@ const zhHant = {
"Uses the Workspace owner's LangBot Account billing and credits.", "Uses the Workspace owner's LangBot Account billing and credits.",
}, },
bots: { bots: {
applyFailed: '設定已儲存,但套用失敗',
internalErrorHint: '發生內部錯誤,請透過錯誤編號查看後端日誌。',
errorReference: '錯誤編號:{{id}}',
adapterEventDebugAction: '測試監聽', adapterEventDebugAction: '測試監聽',
title: '機器人', title: '機器人',
description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口', description: '建立和管理機器人,這是 LangBot 與各個平台連接的入口',
+55
View File
@@ -0,0 +1,55 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
for (const failure of [
{
status: 400,
code: 'invalid_bot_config',
msg: 'Lark missing required config: app_id, app_secret, bot_name',
},
{
status: 400,
code: 'bot_apply_failed',
msg: 'Lark missing required config: app_id, app_secret, bot_name',
},
{
status: 500,
code: 'internal_error',
msg: 'Internal server error',
request_id: 'bot-save-test-reference',
},
]) {
test(`bot save displays actionable ${failure.code}`, async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await page.locator('input[name="name"]').fill('Error Test Bot');
await page.getByRole('button', { name: /^Submit$/ }).click();
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
await page.route('**/api/v1/platform/bots/bot-1', async (route) => {
if (route.request().method() !== 'PUT') return route.fallback();
await route.fulfill({
status: failure.status,
contentType: 'application/json',
body: JSON.stringify(failure),
});
});
await page.getByRole('button', { name: 'Edit basic information' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('Name', { exact: true }).fill('Edited Bot');
await dialog.getByRole('button', { name: /^Save$/ }).click();
if (failure.code === 'internal_error') {
await expect(
page.getByText('Error reference: bot-save-test-reference'),
).toBeVisible();
} else {
await expect(page.getByText(failure.msg, { exact: true })).toBeVisible();
}
if (failure.code === 'bot_apply_failed') {
await expect(
page.getByText('Configuration saved, but could not be applied'),
).toBeVisible();
}
});
}