mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
fix(runner): avoid duplicate inline image payloads (#2631)
* fix(runner): avoid duplicate inline image payloads * fix(runner): preserve attachments and complete HTTP agent delivery Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Qingsong Li <[email protected]> Co-authored-by: Chin Rock <[email protected]> Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
co-authored by
multica-agent
Qingsong Li
Chin Rock
parent
a10a6eb780
commit
41b55952ff
@@ -202,6 +202,12 @@ This is **lossy** (you lose `sequence` / streaming boundaries) and blocks up to
|
||||
`callback_timeout × 4` seconds. Prefer the callback model for anything
|
||||
real-time or multi-part. Only one in-flight `/sync` per `session_id`.
|
||||
|
||||
Collection waits for both a final reply and the inbound listener to finish. An
|
||||
Agent's text reply does not end collection while that Agent is still exporting
|
||||
and sending output files. The same timeout bounds the entire wait; timing out
|
||||
does not cancel the running Agent. Replies sent after timeout use normal callback
|
||||
delivery.
|
||||
|
||||
---
|
||||
|
||||
## 9. Error envelope
|
||||
|
||||
@@ -36,14 +36,24 @@ def prepare_input_files(query, agent_input):
|
||||
event envelope is metadata, never permission to read the Host filesystem.
|
||||
"""
|
||||
from langbot_plugin.api.entities.builtin.runner.input import InputAttachment
|
||||
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
originals = input_files(query)
|
||||
# References are scoped to this query's previous preparation, not to the
|
||||
# platform message order or to any caller-supplied filesystem path.
|
||||
prepared = getattr(query, '_box_inputs', None)
|
||||
previous_refs = {f'attachment-{i}': item for i, item in enumerate(prepared or [])}
|
||||
if not agent_input.attachments:
|
||||
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
|
||||
agent_input.attachments = [
|
||||
InputAttachment.model_validate(item)
|
||||
for item in QueryEntryAdapter._build_attachments(query, [c.model_dump() for c in agent_input.contents])
|
||||
]
|
||||
inline_images = {
|
||||
content.image_base64
|
||||
for content in (agent_input.contents or [])
|
||||
if content.type == 'image_base64' and content.image_base64
|
||||
}
|
||||
components = []
|
||||
for i, attachment in enumerate(agent_input.attachments):
|
||||
kind = (attachment.type or 'file').lower()
|
||||
@@ -52,6 +62,11 @@ def prepare_input_files(query, agent_input):
|
||||
if cls is pm.File:
|
||||
fields.update(name=attachment.name or '', id=attachment.id or '')
|
||||
component = cls(**fields)
|
||||
referenced = previous_refs.get(attachment.ref)
|
||||
if type(referenced) is cls and not attachment.content and not attachment.url:
|
||||
components.append(referenced)
|
||||
attachment.ref, attachment.path = f'attachment-{i}', None
|
||||
continue
|
||||
# Match by payload, never by list position (contents and message chains
|
||||
# can describe attachments in different orders).
|
||||
for original in originals:
|
||||
@@ -77,6 +92,8 @@ def prepare_input_files(query, agent_input):
|
||||
break
|
||||
components.append(component)
|
||||
attachment.ref, attachment.path = f'attachment-{i}', None
|
||||
if kind == 'image' and attachment.content in inline_images:
|
||||
attachment.content = None
|
||||
object.__setattr__(query, '_box_inputs', components)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from ..agent.runner.host_models import (
|
||||
)
|
||||
from ..agent.runner.resource_policy import ResourcePolicyProjector
|
||||
from ..agent.runner.platform_tools import resolve_agent_platform_tool_names
|
||||
from ..agent.runner.query_entry_adapter import QueryEntryAdapter
|
||||
from ..entity.persistence import workspace as persistence_workspace
|
||||
|
||||
from ..api.http.context import ExecutionContext, PrincipalContext, PrincipalType, RequestContext
|
||||
|
||||
@@ -89,7 +89,7 @@ class _SessionOutbound:
|
||||
|
||||
|
||||
class _SyncCollector:
|
||||
"""Collects reply parts for a /sync request and resolves when the turn ends."""
|
||||
"""Collects reply parts and signals receipt of a final message."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.parts: list = []
|
||||
@@ -162,6 +162,9 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def get_supported_apis(self) -> list[str]:
|
||||
return ['send_message', 'reply_message']
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return True
|
||||
|
||||
@@ -582,6 +585,12 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'message': [c.model_dump() if hasattr(c, 'model_dump') else c.__dict__ for c in message],
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
collector = self.sync_waiters.get(str(target_id))
|
||||
if collector is not None:
|
||||
collector.parts.extend(payload['message'])
|
||||
collector.done.set()
|
||||
return payload
|
||||
|
||||
await self._enqueue_callback(str(target_id), payload)
|
||||
return payload
|
||||
|
||||
@@ -612,6 +621,9 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
Lossy by design (drops streaming/ordering nuance); documented as such.
|
||||
Concurrency-safe: routing is via the per-session ``_sync_waiters``
|
||||
registry that ``_emit_reply`` consults, not by patching methods.
|
||||
A final message can precede Agent output-file delivery, so collection
|
||||
also waits for the inbound listener to finish. Legacy queued pipelines
|
||||
may finish their listener before sending any reply.
|
||||
"""
|
||||
if session_id in self.sync_waiters:
|
||||
return self._err('duplicate', 'a sync request is already in flight for this session')
|
||||
@@ -622,12 +634,19 @@ class HttpBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if listener_task is None:
|
||||
self.sync_waiters.pop(session_id, None)
|
||||
return self._err('overloaded', 'too many inbound messages are already being processed')
|
||||
|
||||
async def wait_for_turn():
|
||||
await collector.done.wait()
|
||||
await asyncio.shield(listener_task)
|
||||
|
||||
try:
|
||||
timeout = int(self.config.get('callback_timeout', 15)) * 4
|
||||
try:
|
||||
await asyncio.wait_for(collector.done.wait(), timeout=timeout)
|
||||
await asyncio.wait_for(wait_for_turn(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
await self.logger.warning(f'http_bot sync wait timed out for session {session_id}')
|
||||
except Exception:
|
||||
await self.logger.warning(f'http_bot sync listener failed for session {session_id}')
|
||||
finally:
|
||||
self.sync_waiters.pop(session_id, None)
|
||||
state = self.outbound_states.get(session_id)
|
||||
|
||||
@@ -24,7 +24,7 @@ from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
|
||||
from litellm.secret_managers.main import get_secret, get_secret_bool
|
||||
|
||||
from .. import errors, reasoning, requester
|
||||
from ....utils import httpclient
|
||||
from ....utils import httpclient, image
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
@@ -895,7 +895,9 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
if isinstance(raw_image_url, dict):
|
||||
fallback_url = raw_image_url.get('url')
|
||||
if image_b64 or fallback_url:
|
||||
part['image_url'] = {'url': image_b64 or fallback_url}
|
||||
part['image_url'] = {
|
||||
'url': image.normalize_image_data_url(image_b64) if image_b64 else fallback_url
|
||||
}
|
||||
part['type'] = 'image_url'
|
||||
part.pop('image_base64', None)
|
||||
else:
|
||||
|
||||
@@ -17,6 +17,30 @@ _INSECURE_SSL_CONTEXT.verify_mode = ssl.CERT_NONE
|
||||
DEFAULT_BASE64_MEDIA_LIMIT = 10 * 1024 * 1024
|
||||
|
||||
|
||||
def normalize_image_data_url(value: str) -> str:
|
||||
"""Wrap raw image base64 with its detected MIME type, never a guessed one.
|
||||
|
||||
Existing data URLs are preserved for provider-specific compatibility.
|
||||
Bound decoding before inspecting untrusted image headers.
|
||||
"""
|
||||
if value.startswith('data:'):
|
||||
return value
|
||||
if len(value) > 4 * ((DEFAULT_BASE64_MEDIA_LIMIT + 2) // 3):
|
||||
raise ValueError('Base64 image exceeds the media size limit')
|
||||
try:
|
||||
decoded = base64.b64decode(value, validate=True)
|
||||
if len(decoded) > DEFAULT_BASE64_MEDIA_LIMIT:
|
||||
raise ValueError('Base64 image exceeds the media size limit')
|
||||
with PIL.Image.open(io.BytesIO(decoded)) as image:
|
||||
mime = PIL.Image.MIME.get(image.format or '')
|
||||
image.verify()
|
||||
if not mime or not mime.startswith('image/'):
|
||||
raise ValueError('Unknown image MIME type')
|
||||
except (ValueError, OSError, PIL.Image.DecompressionBombError) as exc:
|
||||
raise ValueError('Invalid base64 image: expected encoded image bytes') from exc
|
||||
return f'data:{mime};base64,{value}'
|
||||
|
||||
|
||||
def _detect_image_format(file_bytes: bytes) -> str:
|
||||
with PIL.Image.open(io.BytesIO(file_bytes)) as image:
|
||||
return str(image.format or 'jpeg').lower()
|
||||
|
||||
@@ -156,6 +156,55 @@ def test_event_attachments_preserved_without_eager_io_or_host_path_access():
|
||||
assert image.attachments[0].url == 'https://example.invalid/image.png'
|
||||
|
||||
|
||||
def test_duplicate_inline_image_payload_is_replaced_by_attachment_reference():
|
||||
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment
|
||||
from langbot.pkg.box.runner import input_files, prepare_input_files
|
||||
|
||||
image_base64 = 'data:image/png;base64,YQ=='
|
||||
query = SimpleNamespace(message_chain=MessageChain([]))
|
||||
value = AgentInput(
|
||||
contents=[ContentElement.from_image_base64(image_base64)],
|
||||
attachments=[
|
||||
InputAttachment(type='image', source='base64', content=image_base64),
|
||||
InputAttachment(type='image', source='base64', content='data:image/png;base64,Yg=='),
|
||||
],
|
||||
)
|
||||
|
||||
prepare_input_files(query, value)
|
||||
|
||||
assert value.contents[0].image_base64 == image_base64
|
||||
assert value.attachments[0].content is None
|
||||
assert value.attachments[0].ref == 'attachment-0'
|
||||
assert value.attachments[1].content == 'data:image/png;base64,Yg=='
|
||||
assert value.attachments[1].ref == 'attachment-1'
|
||||
assert input_files(query)[0].base64 == image_base64
|
||||
|
||||
|
||||
def test_repeated_prepare_preserves_deduplicated_images_after_reordering():
|
||||
from langbot_plugin.api.entities.builtin.provider.message import ContentElement
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment
|
||||
from langbot.pkg.box.runner import input_files, prepare_input_files
|
||||
|
||||
images = ['data:image/png;base64,YQ==', 'data:image/png;base64,Yg==']
|
||||
query = SimpleNamespace(message_chain=MessageChain([]))
|
||||
value = AgentInput(
|
||||
contents=[ContentElement.from_image_base64(image) for image in images],
|
||||
attachments=[InputAttachment(type='image', content=image) for image in images],
|
||||
)
|
||||
prepare_input_files(query, value)
|
||||
value = AgentInput.model_validate(value.model_dump())
|
||||
value.attachments.reverse()
|
||||
for _ in range(3):
|
||||
prepare_input_files(query, value)
|
||||
assert [item.base64 for item in input_files(query)] == images[::-1]
|
||||
assert all(item.content is None for item in value.attachments)
|
||||
|
||||
other_query = SimpleNamespace(message_chain=MessageChain([]))
|
||||
prepare_input_files(other_query, value)
|
||||
assert all(not item.base64 for item in input_files(other_query))
|
||||
|
||||
|
||||
def test_attachment_references_follow_metadata_not_platform_list_order():
|
||||
from langbot_plugin.api.entities.builtin.runner.input import AgentInput, InputAttachment
|
||||
from langbot.pkg.box.runner import prepare_input_files, input_files
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import quart
|
||||
import yaml
|
||||
from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
|
||||
from langbot_plugin.api.entities.builtin.platform import events, message
|
||||
from langbot_plugin.box.client import BoxRuntimeClient
|
||||
from langbot_plugin.api.proxies.runner.box import RunnerBoxAPIMixin
|
||||
from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
|
||||
|
||||
from langbot.pkg.agent.runner.execution_context import build_execution_query
|
||||
from langbot.pkg.agent.runner.platform_tools import (
|
||||
build_platform_tool_resources,
|
||||
execute_platform_tool,
|
||||
freeze_platform_context,
|
||||
resolve_agent_platform_tool_names,
|
||||
resolve_platform_api_call,
|
||||
)
|
||||
from langbot.pkg.box.runner import RunBoxBinding, RunnerBoxService, exported_message, prepare_input_files
|
||||
from langbot.pkg.box.service import BoxService
|
||||
from langbot.pkg.discover.engine import Component
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
from langbot.pkg.platform.sources import http_bot_signing as signing
|
||||
from langbot.pkg.platform.sources.http_bot import HttpBotAdapter
|
||||
|
||||
|
||||
def discovered_adapter():
|
||||
relative = 'pkg/platform/sources/http_bot.yaml'
|
||||
manifest = yaml.safe_load((Path(__file__).parents[3] / 'src/langbot' / relative).read_text())
|
||||
adapter_class = Component('builtin', manifest, relative).get_python_component_class()
|
||||
assert adapter_class is HttpBotAdapter
|
||||
return adapter_class(
|
||||
config={'inbound_secret': 'test-only-secret', 'signature_required': True, 'callback_timeout': 1},
|
||||
logger=AsyncMock(spec=AbstractEventLogger),
|
||||
)
|
||||
|
||||
|
||||
def envelope(bot, adapter, inbound):
|
||||
return bot._eba_event_to_agent_envelope(bot._legacy_message_to_eba_event(inbound, adapter), adapter)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('session_type', ['person', 'group'])
|
||||
@pytest.mark.parametrize('mixed', [False, True])
|
||||
async def test_signed_http_file_reaches_box_and_authorized_agent_reply_reaches_sync(
|
||||
tmp_path, monkeypatch, session_type, mixed
|
||||
):
|
||||
async def inline_thread(function, *args, **kwargs):
|
||||
return function(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(asyncio, 'to_thread', inline_thread)
|
||||
adapter = discovered_adapter()
|
||||
adapter._enqueue_callback = AsyncMock(side_effect=AssertionError('sync reply must not use callback'))
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(uuid='http-test-bot')
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(data={'box': {'enabled': True}}),
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter))),
|
||||
)
|
||||
box = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
box._available = True
|
||||
|
||||
async def write_files(query, subdir, target_dir, pending):
|
||||
for name, content in pending:
|
||||
destination = tmp_path / target_dir.lstrip('/') / name
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(content)
|
||||
return [f'{target_dir}/{name}' for name, content in pending]
|
||||
|
||||
box._write_files_into_sandbox = AsyncMock(side_effect=write_files)
|
||||
runner_box = RunnerBoxService(box)
|
||||
received = []
|
||||
listener_errors = []
|
||||
proof = b'HTTP inbound proof survives event conversion'
|
||||
image = b'\x89PNG\r\n\x1a\n'
|
||||
|
||||
async def listener(inbound, live_adapter):
|
||||
event = envelope(bot, live_adapter, inbound)
|
||||
received.append(event)
|
||||
query = build_execution_query(event, [])
|
||||
prepare_input_files(query, event.input)
|
||||
object.__setattr__(query, '_box_binding', RunBoxBinding('run-test', 'box-test', {}, 'run-test'))
|
||||
imported = await runner_box.import_attachments(query, None)
|
||||
assert len(imported['items']) == (2 if mixed else 1)
|
||||
imported_file = next(item for item in imported['items'] if item['name'] == 'proof.txt')
|
||||
assert (tmp_path / imported_file['path'].lstrip('/')).read_bytes() == proof
|
||||
assert imported_file['size'] == len(proof)
|
||||
if mixed:
|
||||
imported_image = next(item for item in imported['items'] if item['type'] == 'Image')
|
||||
assert (tmp_path / imported_image['path'].lstrip('/')).read_bytes() == image
|
||||
resources, capabilities = build_platform_tool_resources(
|
||||
event, resolve_agent_platform_tool_names({}, event.event_type), ['call']
|
||||
)
|
||||
assert any(tool['tool_name'] == 'event_reply' for tool in resources)
|
||||
assert capabilities['supported_apis'] == ['send_message', 'reply_message']
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': event.bot_id,
|
||||
'resources': {'tools': resources},
|
||||
'platform_context': freeze_platform_context(event),
|
||||
}
|
||||
}
|
||||
chain = message.MessageChain([message.Plain(text=proof.decode())])
|
||||
tool, params, rich = resolve_platform_api_call(
|
||||
session,
|
||||
event.bot_id,
|
||||
'send_message',
|
||||
{'target_type': session_type, 'target_id': 'proof-session', 'message': chain.model_dump()},
|
||||
)
|
||||
assert tool == 'event_reply'
|
||||
await execute_platform_tool(app, object(), session, tool, params, message_chain=rich)
|
||||
|
||||
async def checked_listener(inbound, live_adapter):
|
||||
try:
|
||||
await listener(inbound, live_adapter)
|
||||
except Exception as error:
|
||||
listener_errors.append(error)
|
||||
live_adapter.sync_waiters['proof-session'].done.set()
|
||||
|
||||
adapter.register_listener(events.FriendMessage, checked_listener)
|
||||
adapter.register_listener(events.GroupMessage, checked_listener)
|
||||
web = quart.Quart(__name__)
|
||||
|
||||
@web.post('/bots/http-test-bot/sync')
|
||||
async def sync():
|
||||
return await adapter.handle_unified_webhook('http-test-bot', 'sync', quart.request)
|
||||
|
||||
components = [
|
||||
{'type': 'Plain', 'text': 'Read proof.txt and reply with its contents.'},
|
||||
{'type': 'File', 'name': 'proof.txt', 'base64': base64.b64encode(proof).decode()},
|
||||
]
|
||||
if mixed:
|
||||
components.append({'type': 'Image', 'base64': base64.b64encode(image).decode()})
|
||||
body = json.dumps({'session_id': 'proof-session', 'session_type': session_type, 'message': components}).encode()
|
||||
timestamp, signature = signing.sign('test-only-secret', body)
|
||||
client = web.test_client()
|
||||
rejected = await client.post('/bots/http-test-bot/sync', data=body)
|
||||
assert rejected.status_code == 401
|
||||
assert received == []
|
||||
response = await asyncio.wait_for(
|
||||
client.post(
|
||||
'/bots/http-test-bot/sync',
|
||||
data=body,
|
||||
headers={signing.HEADER_TIMESTAMP: timestamp, signing.HEADER_SIGNATURE: signature},
|
||||
),
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
if listener_errors:
|
||||
raise listener_errors[0]
|
||||
data = (await response.get_json())['data']
|
||||
assert data['message'] == [{'type': 'Plain', 'text': proof.decode()}]
|
||||
assert data['reply_to'].startswith('in_')
|
||||
assert len(received) == 1
|
||||
adapter._enqueue_callback.assert_not_awaited()
|
||||
assert adapter.sync_waiters == {}
|
||||
await asyncio.gather(*adapter.inbound_tasks)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('denial', ['bot', 'target', 'grant', 'operation', 'capability'])
|
||||
def test_live_http_reply_grants_fail_closed(denial):
|
||||
adapter = discovered_adapter()
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(uuid='http-test-bot')
|
||||
inbound, *_ = adapter._build_event({'session_id': 'proof-session', 'message': [{'type': 'Plain', 'text': 'hi'}]})
|
||||
event = envelope(bot, adapter, inbound)
|
||||
if denial == 'capability':
|
||||
event.delivery.platform_capabilities['supported_apis'] = []
|
||||
resources, _ = build_platform_tool_resources(
|
||||
event, ['event_reply'], ['detail' if denial == 'operation' else 'call']
|
||||
)
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': event.bot_id,
|
||||
'resources': {'tools': [] if denial == 'grant' else resources},
|
||||
'platform_context': freeze_platform_context(event),
|
||||
}
|
||||
}
|
||||
with pytest.raises(ValueError, match='not authorized'):
|
||||
resolve_platform_api_call(
|
||||
session,
|
||||
'foreign-bot' if denial == 'bot' else event.bot_id,
|
||||
'send_message',
|
||||
{
|
||||
'target_type': 'person',
|
||||
'target_id': 'foreign-session' if denial == 'target' else 'proof-session',
|
||||
'message': [{'type': 'Plain', 'text': 'reply'}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_send_without_sync_waiter_preserves_callback_delivery():
|
||||
adapter = discovered_adapter()
|
||||
adapter._enqueue_callback = AsyncMock()
|
||||
chain = message.MessageChain([message.Plain(text='async reply')])
|
||||
payload = await adapter.send_message('person', 'async-session', chain)
|
||||
adapter._enqueue_callback.assert_awaited_once_with('async-session', payload)
|
||||
assert payload['message'] == chain.model_dump()
|
||||
assert payload['is_final'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('text_api', ['send', 'reply', 'chunk'])
|
||||
async def test_sync_waits_for_authorized_exported_file_after_final_text(text_api):
|
||||
adapter = discovered_adapter()
|
||||
adapter._enqueue_callback = AsyncMock()
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = SimpleNamespace(uuid='http-test-bot')
|
||||
inbound, session_id, _, message_id = adapter._build_event(
|
||||
{'session_id': 'generated-session', 'message': [{'type': 'Plain', 'text': 'generate a file'}]}
|
||||
)
|
||||
text_sent = asyncio.Event()
|
||||
export_allowed = asyncio.Event()
|
||||
content = b'generated file bytes'
|
||||
|
||||
async def listener(event, live_adapter):
|
||||
text = message.MessageChain([message.Plain(text=content.decode())])
|
||||
if text_api == 'send':
|
||||
await live_adapter.send_message('person', session_id, text)
|
||||
elif text_api == 'reply':
|
||||
await live_adapter.reply_message(event, text)
|
||||
else:
|
||||
await live_adapter.reply_message_chunk(event, None, text, is_final=True)
|
||||
text_sent.set()
|
||||
await export_allowed.wait()
|
||||
agent_event = envelope(bot, live_adapter, event)
|
||||
resources, _ = build_platform_tool_resources(agent_event, ['event_reply'], ['call'])
|
||||
session = {
|
||||
'authorization': {
|
||||
'bot_id': agent_event.bot_id,
|
||||
'resources': {'tools': resources},
|
||||
'platform_context': freeze_platform_context(agent_event),
|
||||
}
|
||||
}
|
||||
query = SimpleNamespace(_box_binding=RunBoxBinding('generated-run', 'box', {}, 'generated-run'))
|
||||
box = SimpleNamespace(
|
||||
_ATTACHMENT_MAX_TOTAL_BYTES=1000000,
|
||||
collect_outbound_attachments=AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
'type': 'File',
|
||||
'name': 'generated-proof.txt',
|
||||
'base64': base64.b64encode(content).decode(),
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
exported = await RunnerBoxService(box).export_files(query)
|
||||
file_ids = [item['id'] for item in exported['items']]
|
||||
app = SimpleNamespace(
|
||||
platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=live_adapter)))
|
||||
)
|
||||
|
||||
class FileReplyAPI(RunnerBoxAPIMixin):
|
||||
async def _box_call(self, rpc_action, **data):
|
||||
assert rpc_action == PluginToRuntimeAction.CALL_PLATFORM_API
|
||||
files = exported_message(query, data['file_ids'])
|
||||
tool, params, rich = resolve_platform_api_call(
|
||||
session,
|
||||
agent_event.bot_id,
|
||||
data['action'],
|
||||
{**data['params'], 'message': files.model_dump()},
|
||||
data['context_tool'],
|
||||
)
|
||||
result = await execute_platform_tool(app, object(), session, tool, params, message_chain=rich)
|
||||
exported_message(query, data['file_ids'], consume=True)
|
||||
return {'result': result}
|
||||
|
||||
await FileReplyAPI().reply_files(file_ids)
|
||||
|
||||
web = quart.Quart(__name__)
|
||||
async with web.app_context():
|
||||
request_task = asyncio.create_task(adapter._run_sync(inbound, listener, session_id, message_id))
|
||||
try:
|
||||
await asyncio.wait_for(text_sent.wait(), timeout=1)
|
||||
done, _ = await asyncio.wait({request_task}, timeout=0.03)
|
||||
assert not done, 'A final text send must not close the active Agent collector'
|
||||
assert session_id in adapter.sync_waiters
|
||||
finally:
|
||||
export_allowed.set()
|
||||
response, status = await asyncio.wait_for(request_task, timeout=2)
|
||||
assert status == 200
|
||||
payload = (await response.get_json())['data']
|
||||
assert payload['reply_to'] == message_id
|
||||
assert len(payload['message']) == 2
|
||||
assert payload['message'][0] == {'type': 'Plain', 'text': content.decode()}
|
||||
delivered = payload['message'][1]
|
||||
assert delivered['type'] == 'File'
|
||||
assert delivered['name'] == 'generated-proof.txt'
|
||||
assert base64.b64decode(delivered['base64'], validate=True) == content
|
||||
adapter._enqueue_callback.assert_not_awaited()
|
||||
assert adapter.sync_waiters == {}
|
||||
assert adapter.outbound_states == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_still_waits_for_queued_legacy_pipeline_reply():
|
||||
adapter = discovered_adapter()
|
||||
listener_returned = asyncio.Event()
|
||||
inbound, session_id, _, message_id = adapter._build_event(
|
||||
{'session_id': 'legacy-session', 'message': [{'type': 'Plain', 'text': 'hi'}]}
|
||||
)
|
||||
|
||||
async def listener(event, live_adapter):
|
||||
listener_returned.set()
|
||||
|
||||
web = quart.Quart(__name__)
|
||||
async with web.app_context():
|
||||
request_task = asyncio.create_task(adapter._run_sync(inbound, listener, session_id, message_id))
|
||||
await asyncio.wait_for(listener_returned.wait(), timeout=1)
|
||||
done, _ = await asyncio.wait({request_task}, timeout=0.03)
|
||||
assert not done
|
||||
await adapter.reply_message(inbound, message.MessageChain([message.Plain(text='queued reply')]))
|
||||
response, status = await asyncio.wait_for(request_task, timeout=1)
|
||||
assert status == 200
|
||||
assert (await response.get_json())['data']['message'] == [{'type': 'Plain', 'text': 'queued reply'}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('cancel_request', [False, True])
|
||||
async def test_sync_timeout_or_disconnect_does_not_cancel_active_listener(cancel_request):
|
||||
adapter = discovered_adapter()
|
||||
adapter.config['callback_timeout'] = 0 if not cancel_request else 1
|
||||
adapter._enqueue_callback = AsyncMock()
|
||||
listener_started = asyncio.Event()
|
||||
finish = asyncio.Event()
|
||||
inbound, session_id, _, message_id = adapter._build_event(
|
||||
{'session_id': 'slow-session', 'message': [{'type': 'Plain', 'text': 'hi'}]}
|
||||
)
|
||||
|
||||
async def listener(event, live_adapter):
|
||||
await live_adapter.send_message('person', session_id, message.MessageChain([message.Plain(text='text')]))
|
||||
listener_started.set()
|
||||
await finish.wait()
|
||||
await live_adapter.send_message('person', session_id, message.MessageChain([message.Plain(text='late')]))
|
||||
|
||||
web = quart.Quart(__name__)
|
||||
async with web.app_context():
|
||||
request_task = asyncio.create_task(adapter._run_sync(inbound, listener, session_id, message_id))
|
||||
await asyncio.wait_for(listener_started.wait(), timeout=1)
|
||||
tasks = tuple(adapter.inbound_tasks)
|
||||
if cancel_request:
|
||||
await asyncio.sleep(0)
|
||||
request_task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await request_task
|
||||
else:
|
||||
_, status = await asyncio.wait_for(request_task, timeout=1)
|
||||
assert status == 200
|
||||
assert adapter.sync_waiters == {}
|
||||
assert tasks and all(not task.done() for task in tasks)
|
||||
finish.set()
|
||||
await asyncio.gather(*tasks)
|
||||
adapter._enqueue_callback.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_listener_failure_after_reply_returns_partial_without_leaking_error():
|
||||
adapter = discovered_adapter()
|
||||
inbound, session_id, _, message_id = adapter._build_event(
|
||||
{'session_id': 'failed-session', 'message': [{'type': 'Plain', 'text': 'hi'}]}
|
||||
)
|
||||
|
||||
async def listener(event, live_adapter):
|
||||
await live_adapter.send_message('person', session_id, message.MessageChain([message.Plain(text='text')]))
|
||||
raise RuntimeError('private failure detail')
|
||||
|
||||
web = quart.Quart(__name__)
|
||||
async with web.app_context():
|
||||
response, status = await asyncio.wait_for(adapter._run_sync(inbound, listener, session_id, message_id), 1)
|
||||
assert status == 200
|
||||
assert 'private failure detail' not in (await response.get_data()).decode()
|
||||
assert adapter.sync_waiters == {}
|
||||
adapter.logger.warning.assert_awaited_once()
|
||||
@@ -0,0 +1,41 @@
|
||||
import base64
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
from langbot.pkg.utils import image
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'format,mime', [('PNG', 'image/png'), ('JPEG', 'image/jpeg'), ('WEBP', 'image/webp'), ('GIF', 'image/gif')]
|
||||
)
|
||||
def test_raw_image_becomes_data_url_without_mutating_input(format, mime):
|
||||
buffer = io.BytesIO()
|
||||
Image.new('RGB', (4, 4), 'red').save(buffer, format=format)
|
||||
raw = base64.b64encode(buffer.getvalue()).decode()
|
||||
msg = provider_message.Message(role='user', content=[provider_message.ContentElement.from_image_base64(raw)])
|
||||
requester = LiteLLMRequester.__new__(LiteLLMRequester)
|
||||
result = requester._convert_messages([msg])
|
||||
assert result[0]['content'][0]['image_url']['url'] == f'data:{mime};base64,{raw}'
|
||||
assert msg.content[0].image_base64 == raw
|
||||
assert requester._convert_messages([msg]) == result
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value', ['!invalid!', 'aGVsbG8=', ''])
|
||||
def test_invalid_raw_image_is_rejected(value):
|
||||
with pytest.raises(ValueError, match='Invalid base64 image'):
|
||||
image.normalize_image_data_url(value)
|
||||
|
||||
|
||||
def test_existing_data_url_is_preserved():
|
||||
value = 'data:image/jpeg;base64,existing-payload'
|
||||
assert image.normalize_image_data_url(value) == value
|
||||
|
||||
|
||||
def test_oversized_raw_input_rejected_before_decode(monkeypatch):
|
||||
monkeypatch.setattr(image, 'DEFAULT_BASE64_MEDIA_LIMIT', 3)
|
||||
with pytest.raises(ValueError, match='size limit'):
|
||||
image.normalize_image_data_url('A' * 8)
|
||||
Reference in New Issue
Block a user