mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-10 05:00:59 +00:00
merge: resolve conflicts with master, add inbound attachment materialization
- Delete localagent.py and test_difysvapi_runner.py (replaced by plugins) - Keep master's tool loader enhancements (byte_offset, encoding params) - Remove feature branch's artifact reference code (use sandbox paths instead) - Add _materialize_inbound_attachments in orchestrator for sandbox file staging - Keep master's test formatting and new tests - Keep master's frontend refactoring
This commit is contained in:
@@ -88,7 +88,10 @@ class AnotherFakeRequester(requester.ProviderAPIRequester):
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs=None, extra_args={}, remove_think=False):
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
return provider_message.Message(role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')])
|
||||
|
||||
return provider_message.Message(
|
||||
role='assistant', content=[provider_message.ContentElement(type='text', text='Another response')]
|
||||
)
|
||||
|
||||
async def invoke_rerank(self, model, query: str, documents: list, extra_args={}):
|
||||
"""Return fake rerank results."""
|
||||
@@ -135,8 +138,10 @@ def mock_app_for_modelmgr():
|
||||
|
||||
# Fake persistence manager - returns empty results by default
|
||||
app.persistence_mgr = SimpleNamespace()
|
||||
|
||||
async def default_execute(query):
|
||||
return _make_mock_result([])
|
||||
|
||||
app.persistence_mgr.execute_async = AsyncMock(side_effect=default_execute)
|
||||
|
||||
# Fake discover engine
|
||||
@@ -165,9 +170,7 @@ def fake_requester_registry(mock_app_for_modelmgr):
|
||||
fake_component = _create_fake_component('fake-requester', FakeProviderAPIRequester)
|
||||
another_component = _create_fake_component('another-fake-requester', AnotherFakeRequester)
|
||||
|
||||
app.discover.get_components_by_kind = Mock(
|
||||
return_value=[fake_component, another_component]
|
||||
)
|
||||
app.discover.get_components_by_kind = Mock(return_value=[fake_component, another_component])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
return model_mgr
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Unit tests for LiteLLMRequester._convert_messages.
|
||||
|
||||
Focus: the content-part normalization that (a) converts image_base64 parts to
|
||||
the OpenAI image_url shape and (b) drops non-image file parts (file_base64 /
|
||||
file_url) which OpenAI-compatible chat models reject. The latter is essential
|
||||
for Voice/File attachments — including ones replayed from conversation history —
|
||||
since the agent consumes their bytes via the sandbox, not the model payload.
|
||||
"""
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
|
||||
|
||||
def _make_requester() -> LiteLLMRequester:
|
||||
# _convert_messages does not touch instance config, so bypass __init__.
|
||||
return LiteLLMRequester.__new__(LiteLLMRequester)
|
||||
|
||||
|
||||
def test_convert_messages_drops_file_base64_part():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('analyze this audio'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
types = [p.get('type') for p in parts]
|
||||
assert 'file_base64' not in types
|
||||
assert types == ['text']
|
||||
assert parts[0]['text'] == 'analyze this audio'
|
||||
|
||||
|
||||
def test_convert_messages_drops_file_url_part():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('here is a doc'),
|
||||
provider_message.ContentElement.from_file_url('http://example.com/report.xlsx', 'report.xlsx'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
types = [p.get('type') for p in out[0]['content']]
|
||||
assert types == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_keeps_image_and_converts_to_image_url():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('look'),
|
||||
provider_message.ContentElement.from_image_base64('data:image/png;base64,AAAA'),
|
||||
],
|
||||
)
|
||||
out = req._convert_messages([msg])
|
||||
parts = out[0]['content']
|
||||
types = [p.get('type') for p in parts]
|
||||
# image is preserved and reshaped to the OpenAI image_url form
|
||||
assert types == ['text', 'image_url']
|
||||
img_part = parts[1]
|
||||
assert img_part['image_url'] == {'url': 'data:image/png;base64,AAAA'}
|
||||
assert 'image_base64' not in img_part
|
||||
|
||||
|
||||
def test_convert_messages_mixed_history_strips_only_files():
|
||||
req = _make_requester()
|
||||
# Simulate replayed history: an old voice turn + a current text turn.
|
||||
history_voice = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('old audio turn'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,BBBB', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
current = provider_message.Message(
|
||||
role='user',
|
||||
content=[provider_message.ContentElement.from_text('now do the csv')],
|
||||
)
|
||||
out = req._convert_messages([history_voice, current])
|
||||
assert [p.get('type') for p in out[0]['content']] == ['text']
|
||||
assert [p.get('type') for p in out[1]['content']] == ['text']
|
||||
|
||||
|
||||
def test_convert_messages_plain_string_content_untouched():
|
||||
req = _make_requester()
|
||||
msg = provider_message.Message(role='user', content='just text')
|
||||
out = req._convert_messages([msg])
|
||||
assert out[0]['content'] == 'just text'
|
||||
@@ -1062,9 +1062,7 @@ class TestScanModels:
|
||||
|
||||
with patch.object(litellmchat.litellm, 'get_model_info') as mock_get_model_info:
|
||||
mock_get_model_info.side_effect = (
|
||||
lambda model: {'max_input_tokens': 131072}
|
||||
if model == 'moonshot/moonshot-v1-128k'
|
||||
else {}
|
||||
lambda model: {'max_input_tokens': 131072} if model == 'moonshot/moonshot-v1-128k' else {}
|
||||
)
|
||||
|
||||
assert requester._safe_context_length('moonshot-v1-128k') == 131072
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Unit tests for LocalAgentRunner._inject_inbound_attachments.
|
||||
|
||||
Covers the user -> sandbox attachment path added for the Box attachment
|
||||
round-trip:
|
||||
|
||||
* materialized descriptors are stashed on the query and described to the model
|
||||
via an appended text note (in-sandbox paths + outbox convention);
|
||||
* non-image file parts (file_base64 / file_url) are stripped from the user
|
||||
message content because OpenAI-compatible chat models reject them, while
|
||||
image and text parts are kept for vision models;
|
||||
* the helper is a no-op when the box service is unavailable or yields nothing,
|
||||
and never raises into the chat turn on materialization failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
def _make_runner(box_service) -> LocalAgentRunner:
|
||||
runner = LocalAgentRunner.__new__(LocalAgentRunner)
|
||||
runner.ap = SimpleNamespace(logger=Mock(), box_service=box_service)
|
||||
return runner
|
||||
|
||||
|
||||
def _make_query():
|
||||
return SimpleNamespace(variables={}, query_id='q-123')
|
||||
|
||||
|
||||
def _box_service(attachments):
|
||||
svc = SimpleNamespace(
|
||||
available=True,
|
||||
OUTBOX_MOUNT_DIR='/outbox',
|
||||
materialize_inbound_attachments=AsyncMock(return_value=attachments),
|
||||
)
|
||||
return svc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_strips_file_parts_and_appends_note():
|
||||
box = _box_service([{'type': 'Voice', 'path': '/inbox/q-123/voice.wav', 'size': 176000}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('transcribe this'),
|
||||
provider_message.ContentElement.from_file_base64('data:audio/wav;base64,AAAA', 'voice.wav'),
|
||||
],
|
||||
)
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
types = [getattr(ce, 'type', None) for ce in user_message.content]
|
||||
# file_base64 dropped; text kept; sandbox-path note appended as text
|
||||
assert 'file_base64' not in types
|
||||
assert types.count('text') == 2
|
||||
note = user_message.content[-1].text
|
||||
assert '/inbox/q-123/voice.wav' in note
|
||||
assert '/outbox/q-123' in note
|
||||
# descriptors stashed for downstream stages
|
||||
assert query.variables['_sandbox_inbound_attachments'] == box.materialize_inbound_attachments.return_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_keeps_image_parts():
|
||||
box = _box_service([{'type': 'Image', 'path': '/inbox/q-123/pic.png', 'size': 1234}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
provider_message.ContentElement.from_text('what is this'),
|
||||
provider_message.ContentElement.from_image_base64('data:image/png;base64,iVBORw0K'),
|
||||
],
|
||||
)
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
types = [getattr(ce, 'type', None) for ce in user_message.content]
|
||||
assert 'image_base64' in types # vision part preserved
|
||||
assert types[-1] == 'text' # note appended last
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_promotes_string_content_to_list_with_note():
|
||||
box = _box_service([{'type': 'File', 'path': '/inbox/q-123/data.csv', 'size': 42}])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='clean this csv')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert isinstance(user_message.content, list)
|
||||
assert [getattr(ce, 'type', None) for ce in user_message.content] == ['text', 'text']
|
||||
assert user_message.content[0].text == 'clean this csv'
|
||||
assert '/inbox/q-123/data.csv' in user_message.content[1].text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_noop_without_box_service():
|
||||
runner = _make_runner(box_service=None)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert user_message.content == 'hello'
|
||||
assert '_sandbox_inbound_attachments' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_noop_when_no_attachments():
|
||||
box = _box_service([])
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
|
||||
assert user_message.content == 'hello'
|
||||
assert '_sandbox_inbound_attachments' not in query.variables
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_inject_swallows_materialization_error():
|
||||
box = SimpleNamespace(
|
||||
available=True,
|
||||
OUTBOX_MOUNT_DIR='/outbox',
|
||||
materialize_inbound_attachments=AsyncMock(side_effect=RuntimeError('disk full')),
|
||||
)
|
||||
runner = _make_runner(box)
|
||||
query = _make_query()
|
||||
user_message = provider_message.Message(role='user', content='hello')
|
||||
|
||||
# must not raise
|
||||
await runner._inject_inbound_attachments(query, user_message)
|
||||
assert user_message.content == 'hello'
|
||||
runner.ap.logger.warning.assert_called_once()
|
||||
@@ -543,6 +543,34 @@ class TestPythonWorkspacePreparation:
|
||||
assert (workspace / '.venv' / 'bin' / 'python').exists()
|
||||
assert (workspace / '.langbot' / 'python-env.lock').is_dir()
|
||||
|
||||
def test_staging_refresh_ignores_unlink_race(self, mcp_module, tmp_path, monkeypatch):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
|
||||
source = tmp_path / 'source'
|
||||
source.mkdir()
|
||||
(source / 'server.py').write_text('print("new")\n', encoding='utf-8')
|
||||
|
||||
process_root = tmp_path / 'shared' / '.mcp' / 'u1'
|
||||
workspace = process_root / 'workspace'
|
||||
workspace.mkdir(parents=True)
|
||||
stale_file = workspace / 'removed.py'
|
||||
stale_file.write_text('stale\n', encoding='utf-8')
|
||||
|
||||
real_unlink = os.unlink
|
||||
|
||||
def unlink_with_race(path):
|
||||
if os.fspath(path) == str(stale_file):
|
||||
real_unlink(path)
|
||||
raise FileNotFoundError(path)
|
||||
real_unlink(path)
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module.os, 'unlink', unlink_with_race)
|
||||
|
||||
mcp_module.BoxStdioSessionRuntime._copy_workspace_tree(str(source), str(process_root), str(workspace))
|
||||
|
||||
assert not stale_file.exists()
|
||||
assert (workspace / 'server.py').read_text(encoding='utf-8') == 'print("new")\n'
|
||||
|
||||
|
||||
# ── get_runtime_info_dict ───────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -635,7 +635,9 @@ async def test_model_manager_reload_provider_not_found(fake_requester_registry):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_load_llm_model_with_provider(fake_requester_registry, fake_persistence_data, runtime_provider):
|
||||
async def test_model_manager_load_llm_model_with_provider(
|
||||
fake_requester_registry, fake_persistence_data, runtime_provider
|
||||
):
|
||||
"""Test ModelManager.load_llm_model_with_provider creates RuntimeLLMModel."""
|
||||
model_mgr = fake_requester_registry
|
||||
|
||||
@@ -648,7 +650,9 @@ async def test_model_manager_load_llm_model_with_provider(fake_requester_registr
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_load_llm_model_with_provider_from_row(fake_requester_registry, fake_persistence_data, runtime_provider):
|
||||
async def test_model_manager_load_llm_model_with_provider_from_row(
|
||||
fake_requester_registry, fake_persistence_data, runtime_provider
|
||||
):
|
||||
"""Test ModelManager.load_llm_model_with_provider handles Row objects."""
|
||||
model_mgr = fake_requester_registry
|
||||
|
||||
@@ -661,7 +665,9 @@ async def test_model_manager_load_llm_model_with_provider_from_row(fake_requeste
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_load_embedding_model_with_provider(fake_requester_registry, fake_persistence_data, runtime_provider):
|
||||
async def test_model_manager_load_embedding_model_with_provider(
|
||||
fake_requester_registry, fake_persistence_data, runtime_provider
|
||||
):
|
||||
"""Test ModelManager.load_embedding_model_with_provider creates RuntimeEmbeddingModel."""
|
||||
model_mgr = fake_requester_registry
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
@@ -114,6 +115,28 @@ def test_token_manager_next_token_ignores_empty_token_list():
|
||||
assert token_mgr.using_token_index == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_model_manager_initialize_skips_space_sync_after_timeout():
|
||||
ap = SimpleNamespace()
|
||||
ap.discover = SimpleNamespace(get_components_by_kind=Mock(return_value=[]))
|
||||
ap.instance_config = SimpleNamespace(data={'space': {'models_sync_timeout': 0.01}})
|
||||
ap.logger = Mock()
|
||||
|
||||
mgr = ModelManager(ap)
|
||||
mgr.load_models_from_db = AsyncMock()
|
||||
|
||||
async def slow_sync():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
mgr.sync_new_models_from_space = AsyncMock(side_effect=slow_sync)
|
||||
|
||||
await mgr.initialize()
|
||||
|
||||
mgr.load_models_from_db.assert_awaited_once()
|
||||
mgr.sync_new_models_from_space.assert_awaited_once()
|
||||
ap.logger.warning.assert_any_call('LangBot Space model sync timed out after 0.01s, skipping startup sync.')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||
|
||||
@@ -43,6 +43,7 @@ class TestableRequester(requester.ProviderAPIRequester):
|
||||
remove_think=False,
|
||||
):
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content=[provider_message.ContentElement(type='text', text='Testable response')],
|
||||
@@ -289,7 +290,9 @@ async def test_runtime_provider_invoke_llm_delegates(runtime_provider, runtime_l
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
|
||||
messages = [
|
||||
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
|
||||
]
|
||||
|
||||
result = await provider.invoke_llm(query, runtime_llm_model, messages)
|
||||
|
||||
@@ -383,7 +386,9 @@ async def test_runtime_provider_invoke_llm_stream_yields_chunks(runtime_provider
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
|
||||
messages = [
|
||||
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
|
||||
]
|
||||
|
||||
chunks = []
|
||||
async for chunk in provider.invoke_llm_stream(query, runtime_llm_model, messages):
|
||||
@@ -684,7 +689,9 @@ async def test_runtime_provider_invoke_llm_propagates_error(mock_app_for_modelmg
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
messages = [provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])]
|
||||
messages = [
|
||||
provider_message.Message(role='user', content=[provider_message.ContentElement(type='text', text='Hello')])
|
||||
]
|
||||
|
||||
with pytest.raises(RequesterError):
|
||||
await provider.invoke_llm(query, model, messages)
|
||||
|
||||
@@ -5,6 +5,7 @@ Tests cover:
|
||||
- Conversation creation with prompts
|
||||
- Session concurrency semaphore
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
@@ -60,11 +61,7 @@ class TestSessionManagerGetSession:
|
||||
"""Create mock app with instance config."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
'concurrency': {
|
||||
'session': 5
|
||||
}
|
||||
}
|
||||
mock_app.instance_config.data = {'concurrency': {'session': 5}}
|
||||
return mock_app
|
||||
|
||||
@pytest.fixture
|
||||
@@ -173,11 +170,7 @@ class TestSessionManagerGetConversation:
|
||||
"""Create mock app with instance config."""
|
||||
mock_app = Mock()
|
||||
mock_app.instance_config = Mock()
|
||||
mock_app.instance_config.data = {
|
||||
'concurrency': {
|
||||
'session': 5
|
||||
}
|
||||
}
|
||||
mock_app.instance_config.data = {'concurrency': {'session': 5}}
|
||||
return mock_app
|
||||
|
||||
@pytest.fixture
|
||||
@@ -201,17 +194,13 @@ class TestSessionManagerGetConversation:
|
||||
return query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_conversation_with_prompt(
|
||||
self, mock_app_with_config, sample_query, sample_session
|
||||
):
|
||||
async def test_creates_conversation_with_prompt(self, mock_app_with_config, sample_query, sample_session):
|
||||
"""Test that get_conversation creates conversation with prompt."""
|
||||
sessionmgr = get_session_module()
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
pipeline_uuid = 'pipeline-123'
|
||||
bot_uuid = 'bot-123'
|
||||
|
||||
@@ -234,21 +223,15 @@ class TestSessionManagerGetConversation:
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
pipeline_uuid = 'pipeline-123'
|
||||
bot_uuid = 'bot-123'
|
||||
|
||||
# First call creates conversation
|
||||
conv1 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
|
||||
)
|
||||
conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid)
|
||||
|
||||
# Second call with same pipeline should return same conversation
|
||||
conv2 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid
|
||||
)
|
||||
conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, pipeline_uuid, bot_uuid)
|
||||
|
||||
assert conv1 is conv2
|
||||
assert len(sample_session.conversations) == 1
|
||||
@@ -262,36 +245,26 @@ class TestSessionManagerGetConversation:
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
|
||||
# First call with pipeline1
|
||||
conv1 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1'
|
||||
)
|
||||
conv1 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-1', 'bot-1')
|
||||
|
||||
# Second call with different pipeline should create new conversation
|
||||
conv2 = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2'
|
||||
)
|
||||
conv2 = await manager.get_conversation(sample_query, sample_session, prompt_config, 'pipeline-2', 'bot-2')
|
||||
|
||||
assert conv1 is not conv2
|
||||
assert len(sample_session.conversations) == 2
|
||||
assert sample_session.using_conversation is conv2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_has_empty_messages(
|
||||
self, mock_app_with_config, sample_query, sample_session
|
||||
):
|
||||
async def test_conversation_has_empty_messages(self, mock_app_with_config, sample_query, sample_session):
|
||||
"""Test that created conversation has empty messages list."""
|
||||
sessionmgr = get_session_module()
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant.'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'You are a helpful assistant.'}]
|
||||
|
||||
conversation = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123'
|
||||
@@ -300,22 +273,17 @@ class TestSessionManagerGetConversation:
|
||||
assert conversation.messages == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_messages_from_config(
|
||||
self, mock_app_with_config, sample_query, sample_session
|
||||
):
|
||||
async def test_prompt_messages_from_config(self, mock_app_with_config, sample_query, sample_session):
|
||||
"""Test that prompt messages are created from prompt_config."""
|
||||
sessionmgr = get_session_module()
|
||||
|
||||
manager = sessionmgr.SessionManager(mock_app_with_config)
|
||||
|
||||
prompt_config = [
|
||||
{'role': 'system', 'content': 'System message'},
|
||||
{'role': 'user', 'content': 'User message'}
|
||||
]
|
||||
prompt_config = [{'role': 'system', 'content': 'System message'}, {'role': 'user', 'content': 'User message'}]
|
||||
|
||||
conversation = await manager.get_conversation(
|
||||
sample_query, sample_session, prompt_config, 'pipeline-123', 'bot-123'
|
||||
)
|
||||
|
||||
assert conversation.prompt.name == 'default'
|
||||
assert len(conversation.prompt.messages) == 2
|
||||
assert len(conversation.prompt.messages) == 2
|
||||
|
||||
@@ -136,6 +136,7 @@ class TestToolManagerSchemaGeneration:
|
||||
assert 'description' in func
|
||||
assert 'parameters' in func
|
||||
|
||||
|
||||
class TestToolManagerExecuteFuncCall:
|
||||
"""Tests for execute_func_call method."""
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
@@ -189,6 +190,78 @@ async def test_write_creates_subdirectories():
|
||||
assert f.read() == 'nested'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_binary_file_as_base64_chunk():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'blob.bin'), 'wb') as f:
|
||||
f.write(b'\x00\x01\x02\x03\x04')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{
|
||||
'path': '/workspace/blob.bin',
|
||||
'encoding': 'base64',
|
||||
'byte_offset': 1,
|
||||
'max_bytes': 2,
|
||||
},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['content'] == base64.b64encode(b'\x01\x02').decode('ascii')
|
||||
assert result['encoding'] == 'base64'
|
||||
assert result['byte_offset'] == 1
|
||||
assert result['length'] == 2
|
||||
assert result['size_bytes'] == 5
|
||||
assert result['has_more'] is True
|
||||
assert result['next_byte_offset'] == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_base64_file_append():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
first = base64.b64encode(b'\x00\x01').decode('ascii')
|
||||
second = base64.b64encode(b'\x02\x03').decode('ascii')
|
||||
await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/blob.bin', 'content': first, 'encoding': 'base64'},
|
||||
_make_query(),
|
||||
)
|
||||
result = await loader.invoke_tool(
|
||||
'write',
|
||||
{
|
||||
'path': '/workspace/blob.bin',
|
||||
'content': second,
|
||||
'encoding': 'base64',
|
||||
'mode': 'append',
|
||||
},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
with open(os.path.join(tmpdir, 'blob.bin'), 'rb') as f:
|
||||
assert f.read() == b'\x00\x01\x02\x03'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_write_base64_rejects_invalid_content():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'write',
|
||||
{'path': '/workspace/blob.bin', 'content': 'not base64!', 'encoding': 'base64'},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is False
|
||||
assert 'invalid base64' in result['error']
|
||||
assert not os.path.exists(os.path.join(tmpdir, 'blob.bin'))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_replaces_unique_string():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -248,3 +321,135 @@ async def test_path_escape_blocked():
|
||||
|
||||
with pytest.raises(ValueError, match='escapes'):
|
||||
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_availability_helper_handles_unavailable_and_errors():
|
||||
from langbot.pkg.provider.tools.loaders.availability import is_box_backend_available
|
||||
|
||||
assert await is_box_backend_available(SimpleNamespace()) is False
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=SimpleNamespace(available=False))) is False
|
||||
|
||||
unavailable_backend = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': False}}),
|
||||
)
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=unavailable_backend)) is False
|
||||
|
||||
failing_backend = SimpleNamespace(
|
||||
available=True,
|
||||
get_status=AsyncMock(side_effect=RuntimeError('box unavailable')),
|
||||
)
|
||||
assert await is_box_backend_available(SimpleNamespace(box_service=failing_backend)) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_supports_offset_limit_and_truncation_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'lines.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('one\ntwo\nthree\nfour\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/lines.txt', 'offset': 2, 'limit': 2},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result == {
|
||||
'ok': True,
|
||||
'content': 'two\nthree',
|
||||
'truncated': True,
|
||||
'truncated_by': 'lines',
|
||||
'start_line': 2,
|
||||
'end_line': 3,
|
||||
'next_offset': 4,
|
||||
'max_lines': 2,
|
||||
'max_bytes': 50 * 1024,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_file_handles_line_larger_than_byte_limit():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'long-line.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('abcdef\n')
|
||||
|
||||
result = await loader.invoke_tool(
|
||||
'read',
|
||||
{'path': '/workspace/long-line.txt', 'max_bytes': 3},
|
||||
_make_query(),
|
||||
)
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'bytes'
|
||||
assert result['next_offset'] == 1
|
||||
assert 'exceeds the 3B read limit' in result['content']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_result_is_capped_and_exposes_preview_metadata():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
box_service = SimpleNamespace(
|
||||
available=True,
|
||||
default_workspace=tmpdir,
|
||||
execute_tool=AsyncMock(
|
||||
return_value={
|
||||
'ok': True,
|
||||
'stdout': 'a' * 60000,
|
||||
'stderr': 'b' * 60000,
|
||||
'exit_code': 0,
|
||||
}
|
||||
),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
|
||||
result = await loader.invoke_tool('exec', {'command': 'python -V'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert len(result['stdout'].encode('utf-8')) == 50 * 1024
|
||||
assert len(result['stderr'].encode('utf-8')) == 50 * 1024
|
||||
assert len(result['preview'].encode('utf-8')) == 50 * 1024
|
||||
assert result['stdout_truncated'] is True
|
||||
assert result['stderr_truncated'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'bytes'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glob_caps_match_count_and_returns_preview():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
for index in range(105):
|
||||
with open(os.path.join(tmpdir, f'file-{index:03d}.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write(str(index))
|
||||
|
||||
result = await loader.invoke_tool('glob', {'path': '/workspace', 'pattern': '*.txt'}, _make_query())
|
||||
|
||||
assert result['ok'] is True
|
||||
assert result['total'] == 105
|
||||
assert len(result['matches']) == 100
|
||||
assert result['preview'] == '\n'.join(result['matches'])
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'matches'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grep_reports_invalid_regex_and_truncates_long_matching_lines():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
loader, _ = _make_loader_with_workspace(tmpdir)
|
||||
with open(os.path.join(tmpdir, 'data.txt'), 'w', encoding='utf-8') as f:
|
||||
f.write('needle ' + ('x' * 600) + '\n')
|
||||
|
||||
invalid = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': '['}, _make_query())
|
||||
result = await loader.invoke_tool('grep', {'path': '/workspace', 'pattern': 'needle'}, _make_query())
|
||||
|
||||
assert invalid['ok'] is False
|
||||
assert 'Invalid regex' in invalid['error']
|
||||
assert result['ok'] is True
|
||||
assert result['truncated'] is True
|
||||
assert result['truncated_by'] == 'line'
|
||||
assert result['matches'][0]['file'] == '/workspace/data.txt'
|
||||
assert result['matches'][0]['content'].endswith('... [truncated]')
|
||||
|
||||
Reference in New Issue
Block a user