mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-29 20:05:25 +08:00
118 lines
4.6 KiB
Python
118 lines
4.6 KiB
Python
"""Export errors are ordinary tool results; no completion recovery hook."""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
from langbot_plugin.api.entities.builtin.runner.box import BoxFile
|
|
|
|
from pkg.agent_core.langbot import LangBotToolExecutor
|
|
from pkg.agent_core.types import ToolCallRequest
|
|
from pkg.box import LocalAgentBox
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_export_error_then_retry():
|
|
ctx = SimpleNamespace(
|
|
export_box_files=AsyncMock(
|
|
side_effect=[RuntimeError("file missing: /out/a"), [BoxFile(id="file", name="a", type="File", size=1)]]
|
|
),
|
|
delivery=SimpleNamespace(automatic_reply=True),
|
|
reply_files=AsyncMock(),
|
|
)
|
|
box = LocalAgentBox(ctx, None)
|
|
box.prepare = AsyncMock()
|
|
executor = LangBotToolExecutor(None, set())
|
|
assembly = SimpleNamespace(tools=[], tool_executor=executor)
|
|
box.install_tool(assembly)
|
|
request = ToolCallRequest.from_raw(
|
|
{
|
|
"id": "export",
|
|
"type": "function",
|
|
"function": {"name": "export_box_files", "arguments": '{"paths": ["/out/a"]}'},
|
|
}
|
|
)
|
|
failed = await executor.execute(executor.prepare(request))
|
|
assert "file missing" in failed.error
|
|
assert not box.file_ids
|
|
success = await executor.execute(executor.prepare(request))
|
|
assert success.error is None
|
|
assert box.file_ids == ["file"]
|
|
assert not hasattr(box, "before_complete")
|
|
assert not hasattr(box, "finish")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("automatic", [False, True])
|
|
async def test_parallel_exports_keep_handles_and_delivery_unique(automatic):
|
|
import asyncio
|
|
|
|
file = BoxFile(id="file", name="a", type="File", size=1, warning="cleanup failed")
|
|
ctx = SimpleNamespace(
|
|
export_box_files=AsyncMock(return_value=[file]),
|
|
delivery=SimpleNamespace(automatic_reply=automatic),
|
|
reply_files=AsyncMock(),
|
|
)
|
|
box = LocalAgentBox(ctx, None)
|
|
box.prepare = AsyncMock()
|
|
results = await asyncio.gather(box.export_files(["/out/a"]), box.export_files(["/out/a"]))
|
|
assert all(r["status"] == "accepted" and r["items"][0]["warning"] == "cleanup failed" for r in results)
|
|
assert box.file_ids == (["file"] if automatic else [])
|
|
assert ctx.reply_files.await_count == (0 if automatic else 1)
|
|
assert not hasattr(box, "before_complete") and not hasattr(box, "finish")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("failure", [RuntimeError("send failed"), PermissionError("no permission")])
|
|
async def test_platform_failure_is_fatal_and_cannot_reexport_or_redeliver(failure):
|
|
from pkg.agent_core.types import NonRetryableToolError
|
|
|
|
ctx = SimpleNamespace(
|
|
export_box_files=AsyncMock(return_value=[BoxFile(id="file", name="a", type="File", size=1)]),
|
|
delivery=SimpleNamespace(automatic_reply=False),
|
|
reply_files=AsyncMock(side_effect=failure),
|
|
)
|
|
box = LocalAgentBox(ctx, None)
|
|
box.prepare = AsyncMock()
|
|
executor = LangBotToolExecutor(None, set())
|
|
box.install_tool(SimpleNamespace(tools=[], tool_executor=executor))
|
|
request = ToolCallRequest.from_raw(
|
|
{"id": "call", "function": {"name": "export_box_files", "arguments": '{"paths":["/out/a"]}'}}
|
|
)
|
|
for _ in range(2):
|
|
with pytest.raises(NonRetryableToolError, match="platform delivery failed"):
|
|
await executor.execute(executor.prepare(request))
|
|
ctx.export_box_files.assert_awaited_once_with(["/out/a"])
|
|
ctx.reply_files.assert_awaited_once_with(["file"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_host_response_is_not_a_generation_retry():
|
|
from pkg.agent_core.types import NonRetryableToolError
|
|
|
|
ctx = SimpleNamespace(
|
|
export_box_files=AsyncMock(
|
|
side_effect=RuntimeError(
|
|
'ActionCallError: LANGBOT_EXPORT_V1:{"code":"box.export.invalid_response","message":"invalid attachment data","retryable":false}'
|
|
)
|
|
)
|
|
)
|
|
box = LocalAgentBox(ctx, None)
|
|
box.prepare = AsyncMock()
|
|
with pytest.raises(NonRetryableToolError, match="invalid attachment data"):
|
|
await box.export_files(["/out/a"])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_sdk_fails_before_acquiring_a_box(monkeypatch):
|
|
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
|
|
|
|
async def old_export(self):
|
|
raise AssertionError("must not be called")
|
|
|
|
monkeypatch.setattr(RunnerContext, "export_box_files", old_export)
|
|
api = SimpleNamespace(get_box_status=AsyncMock())
|
|
with pytest.raises(RuntimeError, match="upgrade them together"):
|
|
await LocalAgentBox(SimpleNamespace(), api).prepare()
|
|
api.get_box_status.assert_not_called()
|