diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 00000000..56055212
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,46 @@
+name: Tests
+
+on:
+ push:
+ branches:
+ - main
+ - test-build
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ unit-tests:
+ name: Unit Tests
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version: ["3.10", "3.11", "3.12"]
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Install uv
+ uses: astral-sh/setup-uv@v6
+ - name: Set up Python
+ run: uv python install ${{ matrix.python-version }}
+ - name: Run tests with coverage
+ run: |
+ uv run --python ${{ matrix.python-version }} pytest \
+ --cov=langbot_plugin \
+ --cov-report=term-missing \
+ --cov-fail-under=75
+
+ test-lint:
+ name: Test Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ - name: Install uv
+ uses: astral-sh/setup-uv@v6
+ - name: Ruff tests
+ run: uv run ruff check tests --output-format=concise
diff --git a/pyproject.toml b/pyproject.toml
index e6b51a05..e4c4aa30 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,6 +27,8 @@ dependencies = [
[dependency-groups]
dev = [
"mypy>=1.16.0",
+ "pytest-asyncio>=1.3.0",
+ "pytest-cov>=7.0.0",
"ruff>=0.11.12",
]
@@ -44,3 +46,17 @@ Issues = "https://github.com/langbot-app/langbot-plugin-sdk/issues"
[tool.setuptools]
package-data = { "langbot_plugin" = ["assets/templates/*", "assets/*.js"] }
+
+[tool.pytest.ini_options]
+addopts = "-ra"
+testpaths = ["tests"]
+asyncio_mode = "auto"
+pythonpath = ["."]
+
+[tool.coverage.run]
+branch = true
+source = ["langbot_plugin"]
+
+[tool.coverage.report]
+show_missing = true
+skip_covered = true
diff --git a/src/langbot_plugin/api/entities/builtin/command/context.py b/src/langbot_plugin/api/entities/builtin/command/context.py
index 4d399ea1..7d8d9a60 100644
--- a/src/langbot_plugin/api/entities/builtin/command/context.py
+++ b/src/langbot_plugin/api/entities/builtin/command/context.py
@@ -37,8 +37,8 @@ class CommandReturn(pydantic.BaseModel):
"""错误,保留供系统使用,插件逻辑报错请自行使用 text 传递
"""
- @classmethod
@pydantic.field_validator("error", mode="before")
+ @classmethod
def _validate_error(
cls, v: Optional[errors.CommandError]
) -> Optional[errors.CommandError]:
@@ -46,9 +46,8 @@ def _validate_error(
return errors.CommandError(message=v.message)
return v
- @classmethod
@pydantic.field_serializer("error")
- def _serialize_error(cls, v: Optional[errors.CommandError]) -> Optional[str]:
+ def _serialize_error(self, v: Optional[errors.CommandError]) -> Optional[str]:
if v is not None:
return v.message
return v
diff --git a/src/langbot_plugin/api/entities/builtin/command/errors.py b/src/langbot_plugin/api/entities/builtin/command/errors.py
index 2d58650e..d1456bb7 100644
--- a/src/langbot_plugin/api/entities/builtin/command/errors.py
+++ b/src/langbot_plugin/api/entities/builtin/command/errors.py
@@ -13,20 +13,20 @@ def __str__(self):
class CommandNotFoundError(CommandError):
- def __init__(self, message: str = None):
+ def __init__(self, message: str = ""):
super().__init__("未知命令: " + message)
class CommandPrivilegeError(CommandError):
- def __init__(self, message: str = None):
+ def __init__(self, message: str = ""):
super().__init__("权限不足: " + message)
class ParamNotEnoughError(CommandError):
- def __init__(self, message: str = None):
+ def __init__(self, message: str = ""):
super().__init__("参数不足: " + message)
class CommandOperationError(CommandError):
- def __init__(self, message: str = None):
+ def __init__(self, message: str = ""):
super().__init__("操作失败: " + message)
diff --git a/src/langbot_plugin/api/entities/builtin/provider/message.py b/src/langbot_plugin/api/entities/builtin/provider/message.py
index 69d1825c..985433cd 100644
--- a/src/langbot_plugin/api/entities/builtin/provider/message.py
+++ b/src/langbot_plugin/api/entities/builtin/provider/message.py
@@ -132,7 +132,8 @@ def get_content_platform_message_chain(
platform_message.File(url=ce.file_url, name=ce.file_name)
)
elif ce.type == "image_url":
- assert ce.image_url is not None
+ if ce.image_url is None:
+ raise ValueError("image_url content requires image_url payload")
if ce.image_url.url.startswith("http"):
mc.append(platform_message.Image(url=ce.image_url.url))
# else: # base64, for backward compatibility
@@ -223,6 +224,8 @@ def get_content_platform_message_chain(
platform_message.File(url=ce.file_url, name=ce.file_name)
)
elif ce.type == "image_url":
+ if ce.image_url is None:
+ raise ValueError("image_url content requires image_url payload")
if ce.image_url.url.startswith("http"):
mc.append(platform_message.Image(url=ce.image_url.url))
# else: # base64
diff --git a/src/langbot_plugin/runtime/io/handler.py b/src/langbot_plugin/runtime/io/handler.py
index 1436c943..0474044f 100644
--- a/src/langbot_plugin/runtime/io/handler.py
+++ b/src/langbot_plugin/runtime/io/handler.py
@@ -70,11 +70,9 @@ def __init__(
@self.action(CommonAction.FILE_CHUNK)
async def file_chunk(data: dict[str, Any]) -> ActionResponse:
file_key = data["file_key"]
- file_length = data["file_length"]
chunk_base64 = data["chunk_base64"]
chunk_index = data["chunk_index"]
chunk_amount = data["chunk_amount"]
- chunk_size = data["chunk_size"]
# append the chunk to the file
async with aiofiles.open(
os.path.join(FILE_STORAGE_DIR, file_key), "ab"
@@ -180,6 +178,8 @@ async def call_action(
return response.data
except asyncio.TimeoutError:
raise ActionCallTimeoutError(f"Action {action.value} call timed out")
+ except ActionCallError:
+ raise
except Exception as e:
raise ActionCallError(f"{e.__class__.__name__}: {str(e)}")
finally:
@@ -218,6 +218,8 @@ async def call_action_generator(
raise ActionCallTimeoutError(
f"Action {action.value} call timed out"
)
+ except ActionCallError:
+ raise
except Exception as e:
raise ActionCallError(f"{e.__class__.__name__}: {str(e)}")
finally:
diff --git a/src/langbot_plugin/runtime/plugin/container.py b/src/langbot_plugin/runtime/plugin/container.py
index 57bb927d..642f495b 100644
--- a/src/langbot_plugin/runtime/plugin/container.py
+++ b/src/langbot_plugin/runtime/plugin/container.py
@@ -10,7 +10,6 @@
from langbot_plugin.api.definition.plugin import BasePlugin
from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent
from langbot_plugin.api.definition.components.manifest import ComponentManifest
-from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
class RuntimeContainerStatus(enum.Enum):
@@ -59,9 +58,7 @@ class PluginContainer(pydantic.BaseModel):
components: list[ComponentContainer]
"""组件容器列表"""
- _runtime_plugin_handler: PluginConnectionHandler | None = pydantic.PrivateAttr(
- default=None
- )
+ _runtime_plugin_handler: typing.Any = pydantic.PrivateAttr(default=None)
class Config:
arbitrary_types_allowed = True
@@ -84,6 +81,8 @@ def model_dump(self):
def from_dict(cls, data: dict[str, typing.Any]) -> PluginContainer:
return cls(
debug=data["debug"],
+ install_source=data.get("install_source", ""),
+ install_info=data.get("install_info", {}),
manifest=ComponentManifest.model_validate(data["manifest"]),
plugin_instance=NonePlugin(),
enabled=data["enabled"],
diff --git a/src/langbot_plugin/runtime/plugin/mgr.py b/src/langbot_plugin/runtime/plugin/mgr.py
index ed874847..f17ea992 100644
--- a/src/langbot_plugin/runtime/plugin/mgr.py
+++ b/src/langbot_plugin/runtime/plugin/mgr.py
@@ -67,6 +67,8 @@ class PluginManager:
def __init__(self, context: context_module.RuntimeContext):
self.context = context
+ self.plugin_handlers = []
+ self.plugins = []
self.plugin_run_tasks = []
self.wait_for_control_connection = None
@@ -621,8 +623,6 @@ async def emit_event(
if resp["emitted"]:
emitted_plugins.append(plugin)
- emitted_plugins.append(plugin)
-
event_context = EventContext.model_validate(resp["event_context"])
if event_context.is_prevented_postorder():
diff --git a/src/langbot_plugin/utils/discover/engine.py b/src/langbot_plugin/utils/discover/engine.py
index 78093566..d4cddbb5 100644
--- a/src/langbot_plugin/utils/discover/engine.py
+++ b/src/langbot_plugin/utils/discover/engine.py
@@ -14,6 +14,9 @@ class ComponentDiscoveryEngine:
components: typing.Dict[str, typing.List[ComponentManifest]] = {}
"""组件列表"""
+ def __init__(self):
+ self.components = {}
+
def load_component_manifest(
self, path: str, owner: str = "builtin", no_save: bool = False
) -> ComponentManifest | None:
diff --git a/src/langbot_plugin/utils/importutil.py b/src/langbot_plugin/utils/importutil.py
index a070c094..4b26ae3e 100644
--- a/src/langbot_plugin/utils/importutil.py
+++ b/src/langbot_plugin/utils/importutil.py
@@ -1,6 +1,8 @@
import importlib
import importlib.util
import os
+import pkgutil
+import sys
import typing
@@ -10,6 +12,12 @@ def import_modules_in_pkg(pkg: typing.Any) -> None:
Args:
pkg: 要导入的包对象
"""
+ if hasattr(pkg, "__path__"):
+ for module_info in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
+ if not module_info.ispkg:
+ importlib.import_module(module_info.name)
+ return
+
pkg_path = os.path.dirname(pkg.__file__)
import_dir(pkg_path)
@@ -20,19 +28,25 @@ def import_modules_in_pkgs(pkgs: typing.List) -> None:
def import_dot_style_dir(dot_sep_path: str):
- sec = dot_sep_path.split(".")
-
- return import_dir(os.path.join(*sec))
+ pkg = importlib.import_module(dot_sep_path)
+ return import_modules_in_pkg(pkg)
def import_dir(path: str):
+ abs_path = os.path.abspath(path)
for file in os.listdir(path):
if file.endswith(".py") and file != "__init__.py":
- full_path = os.path.join(path, file)
- rel_path = full_path.replace(
- os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ""
- )
- rel_path = rel_path[1:]
- rel_path = rel_path.replace("/", ".")[:-3]
- rel_path = rel_path.replace("\\", ".")
- importlib.import_module(rel_path)
+ full_path = os.path.abspath(os.path.join(abs_path, file))
+ module_path = full_path[:-3]
+ for root in sorted(sys.path, key=len, reverse=True):
+ if not root:
+ root = os.getcwd()
+ abs_root = os.path.abspath(root)
+ try:
+ rel_path = os.path.relpath(module_path, abs_root)
+ except ValueError:
+ continue
+ if rel_path.startswith(".."):
+ continue
+ importlib.import_module(rel_path.replace(os.sep, "."))
+ break
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/api/definition/components/test_components.py b/tests/api/definition/components/test_components.py
new file mode 100644
index 00000000..488a0793
--- /dev/null
+++ b/tests/api/definition/components/test_components.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot_plugin.api.definition.components.base import BaseComponent, NoneComponent
+from langbot_plugin.api.definition.components.command.command import Command
+from langbot_plugin.api.definition.components.common.event_listener import EventListener
+from langbot_plugin.api.definition.components.knowledge_engine.engine import (
+ KnowledgeEngine,
+ KnowledgeEngineCapability,
+)
+from langbot_plugin.api.definition.components.page import Page, PageRequest, PageResponse
+from langbot_plugin.api.definition.components.parser.parser import Parser
+from langbot_plugin.api.definition.components.tool.tool import Tool
+from langbot_plugin.api.entities.builtin.command.context import CommandReturn
+from langbot_plugin.api.entities.events import PersonMessageReceived
+
+
+def test_base_and_none_components_initialize_as_noop():
+ component = NoneComponent()
+ assert isinstance(component, BaseComponent)
+
+
+def test_event_listener_registers_multiple_handlers_for_event_type():
+ listener = EventListener()
+
+ async def first(_ctx):
+ return None
+
+ async def second(_ctx):
+ return None
+
+ assert listener.handler(PersonMessageReceived)(first) is first
+ listener.handler(PersonMessageReceived)(second)
+
+ assert listener.registered_handlers[PersonMessageReceived] == [first, second]
+
+
+def test_command_subcommand_decorator_records_metadata():
+ command = Command()
+
+ async def handler(_ctx):
+ yield CommandReturn(text="ok")
+
+ assert command.subcommand("run", help="Run", usage="/run", aliases=["r"])(
+ handler
+ ) is handler
+
+ registered = command.registered_subcommands["run"]
+ assert registered.help == "Run"
+ assert registered.usage == "/run"
+ assert registered.aliases == ["r"]
+
+
+def test_command_subcommand_default_aliases_should_not_be_shared():
+ first = Command()
+ second = Command()
+
+ async def first_handler(_ctx):
+ yield CommandReturn(text="first")
+
+ async def second_handler(_ctx):
+ yield CommandReturn(text="second")
+
+ first.subcommand("first")(first_handler)
+ second.subcommand("second")(second_handler)
+ first.registered_subcommands["first"].aliases.append("alias")
+
+ assert second.registered_subcommands["second"].aliases == []
+
+
+def test_page_request_response_helpers_and_default_handler():
+ request = PageRequest(endpoint="/entries", method="GET", headers={"x": "1"})
+ assert request.body is None
+ assert request.headers == {"x": "1"}
+ assert PageResponse.ok({"ok": True}).data == {"ok": True}
+ assert PageResponse.fail("nope").error == "nope"
+
+
+@pytest.mark.asyncio
+async def test_page_default_handle_api_returns_not_implemented_failure():
+ response = await Page().handle_api(PageRequest(endpoint="/", method="GET"))
+
+ assert response.error == "Not implemented"
+
+
+def test_knowledge_engine_default_capabilities():
+ assert KnowledgeEngine.get_capabilities() == [KnowledgeEngineCapability.DOC_INGESTION]
+
+
+def test_abstract_component_kinds_are_stable():
+ assert Command.__kind__ == "Command"
+ assert EventListener.__kind__ == "EventListener"
+ assert KnowledgeEngine.__kind__ == "KnowledgeEngine"
+ assert Parser.__kind__ == "Parser"
+ assert Tool.__kind__ == "Tool"
+ assert Page.__kind__ == "Page"
diff --git a/tests/api/definition/test_manifest.py b/tests/api/definition/test_manifest.py
new file mode 100644
index 00000000..bbcef42d
--- /dev/null
+++ b/tests/api/definition/test_manifest.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+import os
+import sys
+import textwrap
+
+import pytest
+
+from langbot_plugin.api.definition.components.manifest import (
+ ComponentManifest,
+ I18nString,
+ Metadata,
+ PythonExecution,
+)
+
+
+def _manifest(kind: str = "Tool") -> dict:
+ return {
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": {
+ "name": "weather",
+ "label": {"en_US": "Weather", "zh_Hans": "天气"},
+ "author": "tester",
+ "version": "1.0.0",
+ },
+ "spec": {"description": "lookup weather"},
+ "execution": {"python": {"path": "./weather.py", "attr": "WeatherTool"}},
+ }
+
+
+def test_i18n_string_to_dict_omits_missing_locales():
+ text = I18nString(en_US="Hello", zh_Hans="你好")
+ assert text.to_dict() == {"en_US": "Hello", "zh_Hans": "你好"}
+
+
+def test_metadata_fills_optional_description_and_icon_defaults():
+ metadata = Metadata(name="demo", label={"en_US": "Demo"})
+ assert metadata.description is not None
+ assert metadata.description.to_dict() == {"en_US": ""}
+ assert metadata.icon == ""
+
+
+def test_python_execution_strips_current_directory_prefix():
+ execution = PythonExecution(path="./components/weather.py", attr="Weather")
+ assert execution.path == "components/weather.py"
+
+
+def test_component_manifest_properties_and_plain_dict():
+ component = ComponentManifest(
+ owner="plugin",
+ manifest=_manifest(),
+ rel_path="components/weather.yaml",
+ )
+
+ assert component.kind == "Tool"
+ assert component.metadata.name == "weather"
+ assert component.spec == {"description": "lookup weather"}
+ assert component.icon_rel_path is None
+ assert component.to_plain_dict() == {
+ "name": "weather",
+ "label": {"en_US": "Weather", "zh_Hans": "天气"},
+ "description": {"en_US": ""},
+ "icon": "",
+ "spec": {"description": "lookup weather"},
+ }
+
+
+def test_component_manifest_icon_path_is_relative_to_manifest_directory():
+ manifest = _manifest()
+ manifest["metadata"]["icon"] = "assets/icon.svg"
+ component = ComponentManifest(
+ owner="plugin",
+ manifest=manifest,
+ rel_path="components/weather.yaml",
+ )
+
+ assert component.icon_rel_path == os.path.join("components", "assets/icon.svg")
+
+
+def test_component_manifest_detection_requires_core_fields():
+ assert ComponentManifest.is_component_manifest(_manifest()) is True
+ assert ComponentManifest.is_component_manifest({"kind": "Tool"}) is False
+
+
+def test_component_manifest_imports_python_component_class(tmp_path, monkeypatch):
+ component_dir = tmp_path / "components"
+ component_dir.mkdir()
+ (component_dir / "__init__.py").write_text("", encoding="utf-8")
+ (component_dir / "weather.py").write_text(
+ textwrap.dedent(
+ """
+ class WeatherTool:
+ pass
+ """
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+
+ component = ComponentManifest(
+ owner="plugin",
+ manifest=_manifest(),
+ rel_path="components/weather.yaml",
+ )
+ try:
+ component_cls = component.get_python_component_class()
+ assert component_cls.__name__ == "WeatherTool"
+ finally:
+ while str(tmp_path) in sys.path:
+ sys.path.remove(str(tmp_path))
+
+
+def test_component_manifest_without_execution_cannot_resolve_class():
+ manifest = _manifest()
+ del manifest["execution"]
+ component = ComponentManifest(
+ owner="plugin",
+ manifest=manifest,
+ rel_path="components/weather.yaml",
+ )
+
+ with pytest.raises(ValueError, match="Execution is not set"):
+ component.get_python_component_class()
diff --git a/tests/api/entities/builtin/test_command_context.py b/tests/api/entities/builtin/test_command_context.py
new file mode 100644
index 00000000..f8571913
--- /dev/null
+++ b/tests/api/entities/builtin/test_command_context.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot_plugin.api.entities.builtin.command.context import (
+ CommandReturn,
+ ExecuteContext,
+)
+from langbot_plugin.api.entities.builtin.command.errors import (
+ CommandError,
+ CommandNotFoundError,
+ CommandOperationError,
+ CommandPrivilegeError,
+ ParamNotEnoughError,
+)
+from langbot_plugin.api.entities.builtin.provider.session import (
+ LauncherTypes,
+ Session,
+)
+
+
+def _session() -> Session:
+ return Session(
+ launcher_type=LauncherTypes.PERSON,
+ launcher_id="launcher",
+ sender_id="sender",
+ )
+
+
+def test_command_return_serializes_command_error_to_message():
+ ret = CommandReturn(error=CommandError(message="failed"))
+
+ assert ret.error.message == "failed"
+ assert ret.model_dump(mode="json", by_alias=True)["error"] == "failed"
+
+
+def test_execute_context_shift_advances_current_command_and_params():
+ context = ExecuteContext(
+ query_id=1,
+ session=_session(),
+ command_text="plugin on demo",
+ full_command_text="/plugin on demo",
+ command="plugin",
+ crt_command="plugin",
+ params=["on", "demo"],
+ crt_params=["on", "demo"],
+ privilege=0,
+ )
+
+ assert context.shift() is context
+ assert context.crt_command == "on"
+ assert context.crt_params == ["demo"]
+
+ context.shift()
+ assert context.crt_command == "demo"
+ assert context.crt_params == []
+
+ context.shift()
+ assert context.crt_command == ""
+ assert context.crt_params == []
+
+
+def test_command_not_found_error_default_message_should_be_constructible():
+ assert str(CommandNotFoundError()) == "未知命令: "
+
+
+@pytest.mark.parametrize(
+ ("error", "expected"),
+ [
+ (CommandNotFoundError("demo"), "未知命令: demo"),
+ (CommandPrivilegeError("demo"), "权限不足: demo"),
+ (ParamNotEnoughError("demo"), "参数不足: demo"),
+ (CommandOperationError("demo"), "操作失败: demo"),
+ ],
+)
+def test_command_errors_prefix_user_visible_message(error, expected):
+ assert str(error) == expected
diff --git a/tests/api/entities/builtin/test_platform_logger.py b/tests/api/entities/builtin/test_platform_logger.py
new file mode 100644
index 00000000..d0af7d04
--- /dev/null
+++ b/tests/api/entities/builtin/test_platform_logger.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from langbot_plugin.api.entities.builtin.platform.logger import EventLog, EventLogLevel
+
+
+def test_event_log_to_json_serializes_level_value_and_optional_fields():
+ log = EventLog(
+ seq_id=1,
+ timestamp=123456,
+ level=EventLogLevel.WARNING,
+ text="careful",
+ images=["img"],
+ message_session_id="session",
+ )
+
+ assert log.to_json() == {
+ "seq_id": 1,
+ "timestamp": 123456,
+ "level": "warning",
+ "text": "careful",
+ "images": ["img"],
+ "message_session_id": "session",
+ }
diff --git a/tests/api/entities/builtin/test_provider_message.py b/tests/api/entities/builtin/test_provider_message.py
new file mode 100644
index 00000000..3f4a6e41
--- /dev/null
+++ b/tests/api/entities/builtin/test_provider_message.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot_plugin.api.entities.builtin.platform.message import File, Image, Plain
+from langbot_plugin.api.entities.builtin.provider.message import (
+ ContentElement,
+ FunctionCall,
+ Message,
+ MessageChunk,
+ ToolCall,
+)
+
+
+def test_content_element_factories_and_string_representations():
+ assert str(ContentElement.from_text("hello")) == "hello"
+ assert str(ContentElement.from_image_url("https://example.com/a.png")) == (
+ "[Image](https://example.com/a.png)"
+ )
+ assert str(ContentElement.from_image_base64("abc")) == "[Image](base64)"
+ assert str(ContentElement.from_file_url("https://example.com/a.txt", "a.txt")) == (
+ "[File](https://example.com/a.txt)"
+ )
+ assert str(ContentElement.from_file_base64("abc", "a.txt")) == "[File](a.txt)"
+ assert str(ContentElement(type="unknown")) == "Unknown content"
+
+
+def test_image_url_content_string_is_truncated():
+ long_url = "https://example.com/" + "a" * 200
+ element = ContentElement.from_image_url(long_url)
+
+ assert str(element.image_url).endswith("...")
+ assert len(str(element.image_url)) == 131
+
+
+def test_message_string_content_converts_to_plain_message_chain_with_prefix():
+ message = Message(role="user", content="hello")
+ chain = message.get_content_platform_message_chain(prefix_text="[p] ")
+
+ assert len(chain) == 1
+ assert isinstance(chain[0], Plain)
+ assert chain[0].text == "[p] hello"
+ assert message.readable_str() == "user: hello"
+
+
+def test_message_list_content_converts_supported_elements_to_platform_chain():
+ message = Message(
+ role="user",
+ content=[
+ ContentElement.from_image_url("https://example.com/a.png"),
+ ContentElement.from_file_url("https://example.com/a.txt", "a.txt"),
+ ContentElement.from_image_base64("YmFzZTY0"),
+ ContentElement.from_text("hello"),
+ ],
+ )
+
+ chain = message.get_content_platform_message_chain(prefix_text="[p] ")
+
+ assert [type(component) for component in chain] == [Image, File, Image, Plain]
+ assert chain[-1].text == "[p] hello"
+
+
+def test_message_prefix_is_inserted_when_no_text_component_exists():
+ message = Message(
+ role="user",
+ content=[ContentElement.from_image_url("https://example.com/a.png")],
+ )
+
+ chain = message.get_content_platform_message_chain(prefix_text="[p] ")
+
+ assert isinstance(chain[0], Plain)
+ assert chain[0].text == "[p] "
+ assert isinstance(chain[1], Image)
+
+
+def test_message_without_content_returns_none_and_tool_call_readable_string():
+ call = ToolCall(
+ id="call-1",
+ type="function",
+ function=FunctionCall(name="search", arguments="{}"),
+ )
+ assert Message(role="assistant").get_content_platform_message_chain() is None
+ assert Message(role="assistant", tool_calls=[call]).readable_str() == (
+ "Call tool: call-1"
+ )
+ assert Message(role="assistant").readable_str() == "Unknown message"
+
+
+def test_message_chunk_matches_message_content_conversion():
+ chunk = MessageChunk(
+ role="assistant",
+ content=[ContentElement.from_text("partial")],
+ is_final=False,
+ msg_sequence=3,
+ )
+
+ chain = chunk.get_content_platform_message_chain(prefix_text="[chunk] ")
+
+ assert chain[0].text == "[chunk] partial"
+ assert chunk.readable_str() == "assistant: partial"
+
+
+def test_message_image_url_content_should_validate_required_payload():
+ message = Message(role="user", content=[ContentElement(type="image_url")])
+
+ with pytest.raises(ValueError):
+ message.get_content_platform_message_chain()
diff --git a/tests/api/entities/builtin/test_rag_models.py b/tests/api/entities/builtin/test_rag_models.py
new file mode 100644
index 00000000..0f0e5382
--- /dev/null
+++ b/tests/api/entities/builtin/test_rag_models.py
@@ -0,0 +1,133 @@
+from __future__ import annotations
+
+from langbot_plugin.api.entities.builtin.provider.message import ContentElement
+from langbot_plugin.api.entities.builtin.rag.context import (
+ RetrievalContext,
+ RetrievalResponse,
+ RetrievalResultEntry,
+)
+from langbot_plugin.api.entities.builtin.rag.enums import DocumentStatus
+from langbot_plugin.api.entities.builtin.rag.errors import (
+ CollectionNotFoundError,
+ EmbeddingError,
+ FileServiceError,
+ ParsingError,
+ VectorStoreError,
+)
+from langbot_plugin.api.entities.builtin.rag.models import (
+ FileMetadata,
+ FileObject,
+ IngestionContext,
+ IngestionResult,
+ ParseContext,
+ ParseResult,
+ TextChunk,
+ TextSection,
+)
+
+
+def test_rag_file_and_parse_models_keep_metadata_isolated():
+ first = FileMetadata(
+ filename="a.txt",
+ file_size=3,
+ mime_type="text/plain",
+ document_id="doc-a",
+ knowledge_base_id="kb",
+ )
+ second = FileMetadata(
+ filename="b.txt",
+ file_size=4,
+ mime_type="text/plain",
+ document_id="doc-b",
+ knowledge_base_id="kb",
+ )
+ first.extra["source"] = "upload"
+
+ assert second.extra == {}
+ assert FileObject(metadata=first, storage_path="files/a.txt").storage_path == (
+ "files/a.txt"
+ )
+ assert ParseContext(
+ file_content=b"abc",
+ mime_type="text/plain",
+ filename="a.txt",
+ ).metadata == {}
+
+
+def test_rag_text_models_and_parse_result_defaults():
+ chunk = TextChunk(text="hello", chunk_id="c1", document_id="doc")
+ section = TextSection(content="hello", heading="Intro", page=1)
+ result = ParseResult(text="hello", sections=[section])
+
+ assert chunk.metadata == {}
+ assert chunk.embedding is None
+ assert result.sections[0].heading == "Intro"
+ assert result.metadata == {}
+
+
+def test_ingestion_context_collection_id_falls_back_to_knowledge_base_id():
+ metadata = FileMetadata(
+ filename="a.txt",
+ file_size=3,
+ mime_type="text/plain",
+ document_id="doc",
+ knowledge_base_id="kb",
+ )
+ context = IngestionContext(
+ file_object=FileObject(metadata=metadata, storage_path="files/a.txt"),
+ knowledge_base_id="kb",
+ )
+
+ assert context.get_collection_id() == "kb"
+ context.collection_id = "collection"
+ assert context.get_collection_id() == "collection"
+
+
+def test_ingestion_result_serializes_document_status_enum():
+ result = IngestionResult(
+ document_id="doc",
+ status=DocumentStatus.COMPLETED,
+ chunks_created=2,
+ )
+
+ assert result.model_dump()["status"] is DocumentStatus.COMPLETED
+
+
+def test_retrieval_context_collection_id_fallbacks_and_response_model():
+ context = RetrievalContext(query="hello", knowledge_base_id="kb")
+ assert context.get_collection_id() == "kb"
+ assert RetrievalContext(query="hello").get_collection_id() == ""
+
+ entry = RetrievalResultEntry(
+ id="chunk",
+ content=[ContentElement.from_text("hello")],
+ metadata={"doc": "a"},
+ distance=0.1,
+ score=0.9,
+ )
+ response = RetrievalResponse(results=[entry], total_found=1)
+
+ assert response.results[0].content[0].text == "hello"
+ assert response.metadata == {}
+
+
+def test_rag_host_service_errors_preserve_original_error():
+ original = RuntimeError("backend unavailable")
+
+ embedding_error = EmbeddingError("embedding failed", original)
+ vector_error = VectorStoreError("vector failed", original)
+ file_error = FileServiceError("file failed", original)
+
+ assert str(embedding_error) == "embedding failed"
+ assert embedding_error.original_error is original
+ assert vector_error.original_error is original
+ assert file_error.original_error is original
+
+
+def test_rag_specialized_errors_keep_context_fields():
+ collection_error = CollectionNotFoundError("kb-1")
+ parsing_error = ParsingError("parse failed", file_path="docs/a.txt")
+
+ assert collection_error.collection_id == "kb-1"
+ assert str(collection_error) == "Collection not found or not accessible: kb-1"
+ assert parsing_error.file_path == "docs/a.txt"
diff --git a/tests/api/entities/test_context.py b/tests/api/entities/test_context.py
new file mode 100644
index 00000000..11da7d99
--- /dev/null
+++ b/tests/api/entities/test_context.py
@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+from langbot_plugin.api.entities import context as context_module
+from langbot_plugin.api.definition.abstract.platform.adapter import (
+ AbstractMessagePlatformAdapter,
+)
+from langbot_plugin.api.definition.abstract.platform.event_logger import AbstractEventLogger
+from langbot_plugin.api.entities.builtin.pipeline.query import Query
+from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
+from langbot_plugin.api.entities.builtin.platform.events import FriendMessage
+from langbot_plugin.api.entities.builtin.platform.entities import Friend
+from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
+from langbot_plugin.api.entities.events import PersonMessageReceived
+from langbot_plugin.api.entities.context import EventContext
+
+
+class MockLogger(AbstractEventLogger):
+ async def info(self, text, images=None, message_session_id=None, no_throw=True):
+ pass
+
+ async def debug(self, text, images=None, message_session_id=None, no_throw=True):
+ pass
+
+ async def warning(self, text, images=None, message_session_id=None, no_throw=True):
+ pass
+
+ async def error(self, text, images=None, message_session_id=None, no_throw=True):
+ pass
+
+
+class MockAdapter(AbstractMessagePlatformAdapter):
+ config: dict = {}
+ logger: MockLogger
+
+ async def send_message(self, target_type, target_id, message):
+ pass
+
+ async def reply_message(self, message_source, message, quote_origin=False):
+ pass
+
+ async def is_muted(self, group_id):
+ return False
+
+ def register_listener(self, event_type, callback):
+ pass
+
+ def unregister_listener(self, event_type, callback):
+ pass
+
+ async def run_async(self):
+ pass
+
+ async def kill(self) -> bool:
+ return True
+
+
+def _make_friend_message(chain: MessageChain) -> FriendMessage:
+ return FriendMessage(
+ sender=Friend(id="sender", nickname="Tester", remark=""),
+ message_chain=chain,
+ )
+
+
+def _make_query(chain: MessageChain) -> Query:
+ return Query(
+ query_id=1,
+ launcher_type=LauncherTypes.PERSON,
+ launcher_id="launcher",
+ sender_id="sender",
+ message_event=_make_friend_message(chain),
+ message_chain=chain,
+ adapter=MockAdapter(bot_account_id="bot", config={}, logger=MockLogger()),
+ session=None,
+ )
+
+
+def _event():
+ chain = MessageChain([Plain(text="hello")])
+ return PersonMessageReceived(
+ query=_make_query(chain),
+ launcher_type="person",
+ launcher_id="launcher",
+ sender_id="sender",
+ message_chain=chain,
+ message_event=_make_friend_message(chain),
+ )
+
+
+def test_event_context_from_event_assigns_monotonic_id_and_caches_context():
+ context_module.cached_event_contexts.clear()
+ context_module.global_eid_index = 0
+
+ first = EventContext.from_event(_event())
+ second = EventContext.from_event(_event())
+
+ assert first.eid == 0
+ assert second.eid == 1
+ assert context_module.cached_event_contexts[0] is first
+ assert context_module.cached_event_contexts[1] is second
+ assert first.event_name == "PersonMessageReceived"
+
+
+def test_event_context_prevent_flags_are_mutable_runtime_state():
+ ctx = EventContext.from_event(_event())
+
+ assert ctx.is_prevented_default() is False
+ assert ctx.is_prevented_postorder() is False
+
+ ctx.prevent_default()
+ ctx.prevent_postorder()
+
+ assert ctx.is_prevented_default() is True
+ assert ctx.is_prevented_postorder() is True
+
+
+def test_event_context_validates_event_from_serialized_payload():
+ event = _event()
+ payload = event.model_dump()
+ payload["event_name"] = "PersonMessageReceived"
+
+ ctx = EventContext(
+ query_id=event.query.query_id,
+ eid=99,
+ event_name="PersonMessageReceived",
+ event=payload,
+ )
+
+ assert isinstance(ctx.event, PersonMessageReceived)
+ assert ctx.event.sender_id == "sender"
+
+
+def test_query_variable_helpers_initialize_and_return_runtime_state():
+ query = _make_query(MessageChain([Plain(text="hello")]))
+ query.variables = None
+
+ assert query.get_variable("missing") is None
+ assert query.get_variables() == {}
+
+ query.set_variable("answer", 42)
+
+ assert query.get_variable("answer") == 42
+ assert query.get_variables() == {"answer": 42}
+
+
+def test_query_model_dump_serializes_public_request_payload():
+ query = _make_query(MessageChain([Plain(text="hello")]))
+ query.bot_uuid = "bot-uuid"
+ query.pipeline_uuid = "pipeline-uuid"
+ query.pipeline_config = {"enabled": True}
+
+ payload = query.model_dump()
+
+ assert payload["query_id"] == 1
+ assert payload["launcher_type"] == "person"
+ assert payload["launcher_id"] == "launcher"
+ assert payload["sender_id"] == "sender"
+ assert payload["bot_uuid"] == "bot-uuid"
+ assert payload["pipeline_uuid"] == "pipeline-uuid"
+ assert payload["pipeline_config"] == {"enabled": True}
+ assert payload["session"] is None
+ assert payload["messages"] == []
+ assert payload["prompt"] is None
+ assert payload["message_chain"][0]["text"] == "hello"
diff --git a/tests/api/proxies/test_base.py b/tests/api/proxies/test_base.py
new file mode 100644
index 00000000..7ad9c4ac
--- /dev/null
+++ b/tests/api/proxies/test_base.py
@@ -0,0 +1,11 @@
+from langbot_plugin.api.proxies.base import APIProxy
+
+
+def test_api_proxy_stores_runtime_handler_and_container():
+ runtime_handler = object()
+ plugin_container = object()
+
+ proxy = APIProxy(runtime_handler, plugin_container)
+
+ assert proxy.plugin_runtime_handler is runtime_handler
+ assert proxy.plugin_container is plugin_container
diff --git a/tests/api/proxies/test_langbot_api.py b/tests/api/proxies/test_langbot_api.py
new file mode 100644
index 00000000..b24ad1e2
--- /dev/null
+++ b/tests/api/proxies/test_langbot_api.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import base64
+
+import pytest
+
+from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
+from langbot_plugin.api.entities.builtin.provider.message import Message
+from langbot_plugin.api.proxies.langbot_api import LangBotAPIProxy
+from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
+
+
+class FakeHandler:
+ def __init__(self, responses=None):
+ self.responses = responses or {}
+ self.calls: list[tuple[PluginToRuntimeAction, dict, float | None]] = []
+ self.local_files = {"file-key": b"file-bytes"}
+ self.deleted_files: list[str] = []
+
+ async def call_action(self, action, data, timeout=None):
+ self.calls.append((action, data, timeout))
+ response = self.responses.get(action)
+ if callable(response):
+ return response(data)
+ if response is not None:
+ return response
+ return {}
+
+ async def read_local_file(self, file_key: str) -> bytes:
+ return self.local_files[file_key]
+
+ async def delete_local_file(self, file_key: str) -> None:
+ self.deleted_files.append(file_key)
+
+
+@pytest.mark.asyncio
+async def test_langbot_api_basic_read_methods_unwrap_response_fields():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.GET_LANGBOT_VERSION: {"version": "1.2.3"},
+ PluginToRuntimeAction.GET_BOTS: {"bots": ["bot"]},
+ PluginToRuntimeAction.GET_BOT_INFO: {"bot": {"uuid": "bot"}},
+ PluginToRuntimeAction.GET_LLM_MODELS: {"llm_models": ["model"]},
+ }
+ )
+ proxy = LangBotAPIProxy(handler)
+
+ assert await proxy.get_langbot_version() == "1.2.3"
+ assert await proxy.get_bots() == ["bot"]
+ assert await proxy.get_bot_info("bot") == {"uuid": "bot"}
+ assert await proxy.get_llm_models() == ["model"]
+ assert handler.calls[2] == (
+ PluginToRuntimeAction.GET_BOT_INFO,
+ {"bot_uuid": "bot"},
+ None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_send_message_serializes_message_chain_payload():
+ handler = FakeHandler()
+ proxy = LangBotAPIProxy(handler)
+
+ await proxy.send_message(
+ "bot",
+ "person",
+ "target",
+ MessageChain([Plain(text="hello")]),
+ )
+
+ action, data, timeout = handler.calls[0]
+ assert action is PluginToRuntimeAction.SEND_MESSAGE
+ assert data["message_chain"] == [{"type": "Plain", "text": "hello"}]
+ assert timeout is None
+
+
+@pytest.mark.asyncio
+async def test_invoke_llm_serializes_messages_and_uses_effective_timeout():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.INVOKE_LLM: {
+ "message": {"role": "assistant", "content": "ok"}
+ }
+ }
+ )
+ proxy = LangBotAPIProxy(handler)
+
+ result = await proxy.invoke_llm(
+ "model",
+ [Message(role="user", content="hello")],
+ extra_args={"temperature": 0},
+ )
+
+ assert result == Message(role="assistant", content="ok")
+ action, data, timeout = handler.calls[0]
+ assert action is PluginToRuntimeAction.INVOKE_LLM
+ assert data["timeout"] == 120.0
+ assert timeout == 120.0
+
+
+@pytest.mark.asyncio
+async def test_storage_helpers_encode_and_decode_base64():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.GET_PLUGIN_STORAGE: {
+ "value_base64": base64.b64encode(b"value").decode()
+ },
+ PluginToRuntimeAction.GET_WORKSPACE_STORAGE: {
+ "value_base64": base64.b64encode(b"workspace").decode()
+ },
+ PluginToRuntimeAction.GET_CONFIG_FILE: {
+ "file_base64": base64.b64encode(b"config").decode()
+ },
+ PluginToRuntimeAction.GET_PLUGIN_STORAGE_KEYS: {"keys": ["a"]},
+ PluginToRuntimeAction.GET_WORKSPACE_STORAGE_KEYS: {"keys": ["b"]},
+ }
+ )
+ proxy = LangBotAPIProxy(handler)
+
+ await proxy.set_plugin_storage("k", b"value")
+ assert await proxy.get_plugin_storage("k") == b"value"
+ assert await proxy.get_plugin_storage_keys() == ["a"]
+ await proxy.delete_plugin_storage("k")
+ await proxy.set_workspace_storage("w", b"workspace")
+ assert await proxy.get_workspace_storage("w") == b"workspace"
+ assert await proxy.get_workspace_storage_keys() == ["b"]
+ await proxy.delete_workspace_storage("w")
+ assert await proxy.get_config_file("cfg") == b"config"
+
+ assert handler.calls[0][1] == {"key": "k", "value_base64": "dmFsdWU="}
+ set_workspace_call = [
+ call
+ for call in handler.calls
+ if call[0] is PluginToRuntimeAction.SET_WORKSPACE_STORAGE
+ ][0]
+ assert set_workspace_call[1] == {"key": "w", "value_base64": "d29ya3NwYWNl"}
+
+
+@pytest.mark.asyncio
+async def test_tool_and_rag_helpers_preserve_payload_contracts():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.LIST_PLUGINS_MANIFEST: {"plugins": ["p"]},
+ PluginToRuntimeAction.LIST_COMMANDS: {"commands": ["cmd"]},
+ PluginToRuntimeAction.LIST_TOOLS: {"tools": [{"name": "tool"}]},
+ PluginToRuntimeAction.GET_TOOL_DETAIL: {"tool": {"name": "tool"}},
+ PluginToRuntimeAction.CALL_TOOL: {"tool_response": {"ok": True}},
+ PluginToRuntimeAction.INVOKE_EMBEDDING: {"vectors": [[0.1]]},
+ PluginToRuntimeAction.VECTOR_SEARCH: {"results": [{"id": "1"}]},
+ PluginToRuntimeAction.VECTOR_DELETE: {"count": 2},
+ PluginToRuntimeAction.VECTOR_LIST: {"items": [], "total": 0},
+ PluginToRuntimeAction.LIST_KNOWLEDGE_BASES: {"knowledge_bases": []},
+ PluginToRuntimeAction.RETRIEVE_KNOWLEDGE: {"results": []},
+ }
+ )
+ proxy = LangBotAPIProxy(handler)
+
+ assert await proxy.list_plugins_manifest() == ["p"]
+ assert await proxy.list_commands() == ["cmd"]
+ assert await proxy.list_tools() == [{"name": "tool"}]
+ assert await proxy.get_tool_detail("tool") == {"name": "tool"}
+ assert await proxy.call_tool("tool", {"q": 1}, {"s": 1}, 7) == {"ok": True}
+ assert await proxy.invoke_embedding("embed", ["hi"]) == [[0.1]]
+ await proxy.vector_upsert("c", [[0.1]], ["id"], documents=["doc"])
+ assert await proxy.vector_search("c", [0.1], filters={"a": 1}) == [{"id": "1"}]
+ assert await proxy.vector_delete("c", file_ids=["f"]) == 2
+ assert await proxy.vector_list("c") == {"items": [], "total": 0}
+ assert await proxy.list_knowledge_bases() == []
+ assert await proxy.retrieve_knowledge("kb", "query") == []
+
+ call_tool_call = [
+ call for call in handler.calls if call[0] is PluginToRuntimeAction.CALL_TOOL
+ ][0]
+ assert call_tool_call[1]["tool_parameters"] == {"q": 1}
+ assert call_tool_call[2] == 180
+
+
+@pytest.mark.asyncio
+async def test_get_knowledge_file_stream_reads_and_deletes_local_chunk_file():
+ handler = FakeHandler(
+ {PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM: {"file_key": "file-key"}}
+ )
+ proxy = LangBotAPIProxy(handler)
+
+ assert await proxy.get_knowledge_file_stream("storage/path") == b"file-bytes"
+ assert handler.deleted_files == ["file-key"]
+
+
+@pytest.mark.asyncio
+async def test_parser_helpers_preserve_payload_contracts():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.LIST_PARSERS: {"parsers": [{"name": "parser"}]},
+ PluginToRuntimeAction.INVOKE_PARSER: {"text": "parsed"},
+ }
+ )
+ proxy = LangBotAPIProxy(handler)
+
+ assert await proxy.list_parsers("text/plain") == [{"name": "parser"}]
+ assert await proxy.invoke_parser(
+ "author",
+ "parser",
+ "files/a.txt",
+ "text/plain",
+ "a.txt",
+ ) == {"text": "parsed"}
+ assert handler.calls[-1] == (
+ PluginToRuntimeAction.INVOKE_PARSER,
+ {
+ "plugin_author": "author",
+ "plugin_name": "parser",
+ "storage_path": "files/a.txt",
+ "mime_type": "text/plain",
+ "filename": "a.txt",
+ "metadata": {},
+ },
+ 300,
+ )
diff --git a/tests/api/proxies/test_query_based_api.py b/tests/api/proxies/test_query_based_api.py
new file mode 100644
index 00000000..96f3c2ca
--- /dev/null
+++ b/tests/api/proxies/test_query_based_api.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot_plugin.api.entities.builtin.platform.message import MessageChain, Plain
+from langbot_plugin.api.proxies.query_based_api import QueryBasedAPIProxy
+from langbot_plugin.entities.io.actions.enums import PluginToRuntimeAction
+
+
+class FakeHandler:
+ def __init__(self, responses=None):
+ self.responses = responses or {}
+ self.calls = []
+
+ async def call_action(self, action, data, timeout=None):
+ self.calls.append((action, data, timeout))
+ return self.responses.get(action, {})
+
+
+@pytest.mark.asyncio
+async def test_query_based_proxy_reply_serializes_query_and_message_chain():
+ handler = FakeHandler()
+ proxy = QueryBasedAPIProxy.model_construct(
+ query_id=123, plugin_runtime_handler=handler
+ )
+
+ await proxy.reply(MessageChain([Plain(text="hi")]), quote_origin=True)
+
+ assert handler.calls == [
+ (
+ PluginToRuntimeAction.REPLY_MESSAGE,
+ {
+ "query_id": 123,
+ "message_chain": [{"type": "Plain", "text": "hi"}],
+ "quote_origin": True,
+ },
+ 180,
+ )
+ ]
+ assert proxy.model_dump() == {"query_id": 123}
+
+
+@pytest.mark.asyncio
+async def test_query_based_proxy_query_var_helpers():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.GET_BOT_UUID: {"bot_uuid": "bot"},
+ PluginToRuntimeAction.GET_QUERY_VAR: {"value": "v"},
+ PluginToRuntimeAction.GET_QUERY_VARS: {"vars": {"k": "v"}},
+ PluginToRuntimeAction.CREATE_NEW_CONVERSATION: {"uuid": "conv"},
+ }
+ )
+ proxy = QueryBasedAPIProxy.model_construct(query_id=7, plugin_runtime_handler=handler)
+
+ assert await proxy.get_bot_uuid() == "bot"
+ await proxy.set_query_var("k", "v")
+ assert await proxy.get_query_var("k") == "v"
+ assert await proxy.get_query_vars() == {"k": "v"}
+ assert await proxy.create_new_conversation() == {"uuid": "conv"}
+
+ assert handler.calls[1] == (
+ PluginToRuntimeAction.SET_QUERY_VAR,
+ {"query_id": 7, "key": "k", "value": "v"},
+ None,
+ )
+
+
+@pytest.mark.asyncio
+async def test_query_based_proxy_pipeline_knowledge_helpers():
+ handler = FakeHandler(
+ {
+ PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES: {
+ "knowledge_bases": [{"uuid": "kb"}]
+ },
+ PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE: {
+ "results": [{"content": "hit"}]
+ },
+ }
+ )
+ proxy = QueryBasedAPIProxy.model_construct(query_id=7, plugin_runtime_handler=handler)
+
+ assert await proxy.list_pipeline_knowledge_bases() == [{"uuid": "kb"}]
+ assert await proxy.retrieve_knowledge("kb", "query", top_k=3) == [
+ {"content": "hit"}
+ ]
+ assert handler.calls[-1] == (
+ PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE,
+ {
+ "query_id": 7,
+ "kb_id": "kb",
+ "query_text": "query",
+ "top_k": 3,
+ "filters": {},
+ },
+ None,
+ )
diff --git a/tests/cli/run/test_controller.py b/tests/cli/run/test_controller.py
new file mode 100644
index 00000000..d7a51d90
--- /dev/null
+++ b/tests/cli/run/test_controller.py
@@ -0,0 +1,172 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from langbot_plugin.api.definition.components.base import NoneComponent
+from langbot_plugin.api.definition.components.common.event_listener import EventListener
+from langbot_plugin.api.definition.components.manifest import ComponentManifest
+from langbot_plugin.api.definition.components.tool.tool import Tool
+from langbot_plugin.api.definition.plugin import BasePlugin, NonePlugin
+from langbot_plugin.api.entities.builtin.provider import session as provider_session
+from langbot_plugin.cli.run.controller import PluginRuntimeController
+from langbot_plugin.runtime.plugin.container import RuntimeContainerStatus
+
+
+class DemoPlugin(BasePlugin):
+ initialized = False
+
+ async def initialize(self) -> None:
+ self.initialized = True
+
+
+class DemoTool(Tool):
+ initialized = False
+
+ async def initialize(self) -> None:
+ self.initialized = True
+
+ async def call(
+ self,
+ params: dict[str, Any],
+ session: provider_session.Session,
+ query_id: int,
+ ) -> str:
+ return "ok"
+
+
+class DemoEventListener(EventListener):
+ initialized = False
+
+ async def initialize(self) -> None:
+ self.initialized = True
+
+
+def _manifest(kind: str, name: str) -> ComponentManifest:
+ return ComponentManifest(
+ owner="tester",
+ rel_path=f"{name}.yaml",
+ manifest={
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": {
+ "name": name,
+ "label": {"en_US": name.title()},
+ "author": "tester",
+ "version": "1.0.0",
+ },
+ "spec": {},
+ "execution": {"python": {"path": f"./{name}.py", "attr": name.title()}},
+ },
+ )
+
+
+def _controller() -> PluginRuntimeController:
+ return PluginRuntimeController(
+ plugin_manifest=_manifest("Plugin", "demo"),
+ component_manifests=[
+ _manifest("Tool", "lookup"),
+ _manifest("EventListener", "events"),
+ _manifest("UnknownKind", "unknown"),
+ ],
+ stdio=True,
+ ws_debug_url="ws://runtime/plugin/ws",
+ )
+
+
+def test_controller_builds_unmounted_placeholder_container():
+ controller = _controller()
+
+ assert controller._stdio is True
+ assert controller.ws_debug_url == "ws://runtime/plugin/ws"
+ assert controller.plugin_container.status is RuntimeContainerStatus.UNMOUNTED
+ assert isinstance(controller.plugin_container.plugin_instance, NonePlugin)
+ assert [component.manifest.kind for component in controller.plugin_container.components] == [
+ "Tool",
+ "EventListener",
+ "UnknownKind",
+ ]
+ assert all(
+ isinstance(component.component_instance, NoneComponent)
+ for component in controller.plugin_container.components
+ )
+
+
+@pytest.mark.asyncio
+async def test_initialize_creates_plugin_and_supported_component_instances(monkeypatch):
+ controller = _controller()
+ controller.handler = object()
+ component_classes = {
+ "Plugin": DemoPlugin,
+ "Tool": DemoTool,
+ "EventListener": DemoEventListener,
+ }
+
+ def fake_component_class(self: ComponentManifest):
+ return component_classes[self.kind]
+
+ monkeypatch.setattr(
+ ComponentManifest,
+ "get_python_component_class",
+ fake_component_class,
+ )
+
+ await controller.initialize(
+ {
+ "enabled": False,
+ "priority": 42,
+ "plugin_config": {"token": "secret"},
+ }
+ )
+
+ plugin = controller.plugin_container.plugin_instance
+ assert isinstance(plugin, DemoPlugin)
+ assert plugin.initialized is True
+ assert plugin.config == {"token": "secret"}
+ assert plugin.plugin_runtime_handler is controller.handler
+ assert controller.plugin_container.enabled is False
+ assert controller.plugin_container.priority == 42
+ assert controller.plugin_container.status is RuntimeContainerStatus.INITIALIZED
+
+ tool, event_listener, unknown = controller.plugin_container.components
+ assert isinstance(tool.component_instance, DemoTool)
+ assert tool.component_instance.initialized is True
+ assert tool.component_instance.plugin is plugin
+ assert isinstance(event_listener.component_instance, DemoEventListener)
+ assert event_listener.component_instance.initialized is True
+ assert event_listener.component_instance.plugin is plugin
+ assert isinstance(unknown.component_instance, NoneComponent)
+
+
+@pytest.mark.asyncio
+async def test_cleanup_instances_resets_runtime_objects(monkeypatch):
+ controller = _controller()
+ controller.handler = object()
+
+ component_classes = {
+ "Plugin": DemoPlugin,
+ "Tool": DemoTool,
+ "EventListener": DemoEventListener,
+ }
+
+ def fake_component_class(self: ComponentManifest):
+ return component_classes[self.kind]
+
+ monkeypatch.setattr(
+ ComponentManifest,
+ "get_python_component_class",
+ fake_component_class,
+ )
+
+ await controller.initialize(
+ {"enabled": True, "priority": 0, "plugin_config": {}}
+ )
+ await controller.cleanup_instances()
+
+ assert isinstance(controller.plugin_container.plugin_instance, NonePlugin)
+ assert controller.plugin_container.status is RuntimeContainerStatus.UNMOUNTED
+ assert all(
+ isinstance(component.component_instance, NoneComponent)
+ for component in controller.plugin_container.components
+ )
diff --git a/tests/cli/run/test_runtime_handler.py b/tests/cli/run/test_runtime_handler.py
new file mode 100644
index 00000000..353b7d09
--- /dev/null
+++ b/tests/cli/run/test_runtime_handler.py
@@ -0,0 +1,338 @@
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+from langbot_plugin.api.definition.components.base import NoneComponent
+from langbot_plugin.api.definition.components.page import Page, PageRequest
+from langbot_plugin.api.definition.components.tool.tool import Tool
+from langbot_plugin.cli.run.handler import PluginRuntimeHandler, _resolve_asset_path
+from langbot_plugin.entities.io.actions.enums import RuntimeToPluginAction
+
+from tests.helpers.protocol import ProtocolConnection, ProtocolSession
+
+
+class FakeManifest:
+ def __init__(self, kind: str = "Plugin", name: str = "demo"):
+ self.kind = kind
+ self.metadata = SimpleNamespace(name=name)
+ self.icon_rel_path = None
+
+ def model_dump(self, **kwargs):
+ return {
+ "kind": self.kind,
+ "metadata": {"name": self.metadata.name},
+ }
+
+
+class FakePluginContainer:
+ def __init__(self):
+ self.manifest = FakeManifest()
+ self.components = []
+
+ def model_dump(self, **kwargs):
+ return {
+ "manifest": self.manifest.model_dump(),
+ "components": [
+ component.manifest.model_dump() for component in self.components
+ ],
+ }
+
+
+class FakeComponentContainer:
+ def __init__(self, kind: str, name: str, component_instance):
+ self.manifest = FakeManifest(kind=kind, name=name)
+ self.component_instance = component_instance
+
+
+class FakePage(Page):
+ def __init__(self):
+ self.requests = []
+
+ async def handle_api(self, request: PageRequest):
+ self.requests.append(request)
+ return {"endpoint": request.endpoint, "body": request.body}
+
+
+class FakeTool(Tool):
+ def __init__(self):
+ self.calls = []
+
+ async def call(self, params, session, query_id):
+ self.calls.append((params, session, query_id))
+ return {"ok": True, "sender_id": session.sender_id, "query_id": query_id}
+
+
+def _handler():
+ initialized = []
+
+ async def initialize(plugin_settings):
+ initialized.append(plugin_settings)
+
+ handler = PluginRuntimeHandler(ProtocolConnection(), initialize)
+ handler.plugin_container = FakePluginContainer()
+ return handler, initialized
+
+
+def test_resolve_asset_path_accepts_assets_and_component_page_files(tmp_path, monkeypatch):
+ assets_file = tmp_path / "assets" / "icon.svg"
+ page_file = tmp_path / "components" / "pages" / "settings.html"
+ outside_file = tmp_path / "components" / "tools" / "secret.txt"
+ assets_file.parent.mkdir()
+ page_file.parent.mkdir(parents=True)
+ outside_file.parent.mkdir(parents=True)
+ assets_file.write_text("icon", encoding="utf-8")
+ page_file.write_text("page", encoding="utf-8")
+ outside_file.write_text("secret", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ assert _resolve_asset_path("icon.svg") == assets_file.resolve()
+ assert _resolve_asset_path("components/pages/settings.html") == page_file.resolve()
+ assert _resolve_asset_path("components/tools/secret.txt") is None
+ assert _resolve_asset_path(str(assets_file.resolve())) is None
+
+
+async def test_plugin_runtime_handler_initializes_plugin_and_returns_container():
+ handler, initialized = _handler()
+
+ async with ProtocolSession(handler) as session:
+ initialized_response = await session.request(
+ RuntimeToPluginAction.INITIALIZE_PLUGIN.value,
+ {"plugin_settings": {"enabled": True}},
+ seq_id=1,
+ )
+ container_response = await session.request(
+ RuntimeToPluginAction.GET_PLUGIN_CONTAINER.value,
+ seq_id=2,
+ )
+
+ assert initialized_response["code"] == 0
+ assert initialized == [{"enabled": True}]
+ assert container_response["data"] == {
+ "manifest": {"kind": "Plugin", "metadata": {"name": "demo"}},
+ "components": [],
+ }
+
+
+async def test_plugin_runtime_handler_icon_without_icon_path_returns_empty_payload():
+ handler, _initialized = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(RuntimeToPluginAction.GET_PLUGIN_ICON.value)
+
+ assert response["data"] == {"plugin_icon_file_key": "", "mime_type": ""}
+
+
+async def test_plugin_runtime_handler_sends_readme_file_key(tmp_path, monkeypatch):
+ handler, _initialized = _handler()
+ (tmp_path / "readme").mkdir()
+ (tmp_path / "readme" / "README_zh.md").write_bytes(b"zh readme")
+ monkeypatch.chdir(tmp_path)
+ sent = []
+
+ async def fake_send_file(file_bytes, extension):
+ sent.append((file_bytes, extension))
+ return "readme-key"
+
+ monkeypatch.setattr(handler, "send_file", fake_send_file)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ RuntimeToPluginAction.GET_PLUGIN_README.value,
+ {"language": "zh"},
+ )
+
+ assert sent == [(b"zh readme", "md")]
+ assert response["data"] == {
+ "plugin_readme_file_key": "readme-key",
+ "mime_type": "text/markdown",
+ }
+
+
+async def test_plugin_runtime_handler_get_assets_file_rejects_path_traversal(
+ tmp_path,
+ monkeypatch,
+):
+ handler, _initialized = _handler()
+ outside = tmp_path / "secret.txt"
+ outside.write_text("secret", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ RuntimeToPluginAction.GET_PLUGIN_ASSETS_FILE.value,
+ {"file_key": "../secret.txt"},
+ )
+
+ assert response["data"] == {"file_file_key": None, "mime_type": None}
+
+
+async def test_plugin_runtime_handler_get_assets_file_sends_allowed_asset(
+ tmp_path,
+ monkeypatch,
+):
+ handler, _initialized = _handler()
+ asset = tmp_path / "assets" / "config.json"
+ asset.parent.mkdir()
+ asset.write_bytes(b'{"ok": true}')
+ monkeypatch.chdir(tmp_path)
+ sent = []
+
+ async def fake_send_file(file_bytes, extension):
+ sent.append((file_bytes, extension))
+ return "asset-key"
+
+ monkeypatch.setattr(handler, "send_file", fake_send_file)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ RuntimeToPluginAction.GET_PLUGIN_ASSETS_FILE.value,
+ {"file_key": "config.json"},
+ )
+
+ assert sent == [(b'{"ok": true}', "")]
+ assert response["data"] == {
+ "file_file_key": "asset-key",
+ "mime_type": "application/json",
+ }
+
+
+async def test_plugin_runtime_handler_page_api_reports_missing_and_uninitialized_page():
+ handler, _initialized = _handler()
+ handler.plugin_container.components = [
+ FakeComponentContainer(Page.__kind__, "settings", NoneComponent())
+ ]
+
+ async with ProtocolSession(handler) as session:
+ missing_page_id = await session.request(
+ RuntimeToPluginAction.PAGE_API.value,
+ {},
+ seq_id=1,
+ )
+ uninitialized = await session.request(
+ RuntimeToPluginAction.PAGE_API.value,
+ {"page_id": "settings"},
+ seq_id=2,
+ )
+ missing = await session.request(
+ RuntimeToPluginAction.PAGE_API.value,
+ {"page_id": "missing"},
+ seq_id=3,
+ )
+
+ assert missing_page_id["data"] == {
+ "data": None,
+ "error": "page_id is required",
+ }
+ assert uninitialized["data"] == {
+ "data": None,
+ "error": "Page component is not initialized",
+ }
+ assert missing["data"] == {"data": None, "error": "Page 'missing' not found"}
+
+
+async def test_plugin_runtime_handler_page_api_invokes_page_component():
+ handler, _initialized = _handler()
+ page = FakePage()
+ handler.plugin_container.components = [
+ FakeComponentContainer(Page.__kind__, "settings", page)
+ ]
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ RuntimeToPluginAction.PAGE_API.value,
+ {
+ "page_id": "settings",
+ "endpoint": "/save",
+ "method": "PUT",
+ "body": {"enabled": True},
+ },
+ )
+
+ assert response["data"] == {
+ "data": {"endpoint": "/save", "body": {"enabled": True}},
+ "error": None,
+ }
+ assert page.requests[0].method == "PUT"
+
+
+async def test_plugin_runtime_handler_call_tool_invokes_matching_tool_component():
+ handler, _initialized = _handler()
+ tool = FakeTool()
+ handler.plugin_container.components = [
+ FakeComponentContainer(Tool.__kind__, "weather", tool)
+ ]
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ RuntimeToPluginAction.CALL_TOOL.value,
+ {
+ "tool_name": "weather",
+ "tool_parameters": {"city": "Shanghai"},
+ "session": {
+ "launcher_type": "person",
+ "launcher_id": "launcher",
+ "sender_id": "sender",
+ },
+ "query_id": 7,
+ },
+ )
+
+ assert response["data"] == {
+ "tool_response": {"ok": True, "sender_id": "sender", "query_id": 7}
+ }
+ assert tool.calls[0][0] == {"city": "Shanghai"}
+
+
+async def test_plugin_runtime_handler_call_tool_reports_missing_or_uninitialized_tool():
+ handler, _initialized = _handler()
+ handler.plugin_container.components = [
+ FakeComponentContainer(Tool.__kind__, "weather", NoneComponent())
+ ]
+
+ async with ProtocolSession(handler) as session:
+ uninitialized = await session.request(
+ RuntimeToPluginAction.CALL_TOOL.value,
+ {
+ "tool_name": "weather",
+ "tool_parameters": {},
+ "session": {
+ "launcher_type": "person",
+ "launcher_id": "launcher",
+ "sender_id": "sender",
+ },
+ "query_id": 1,
+ },
+ seq_id=1,
+ )
+ missing = await session.request(
+ RuntimeToPluginAction.CALL_TOOL.value,
+ {
+ "tool_name": "missing",
+ "tool_parameters": {},
+ "session": {},
+ "query_id": 1,
+ },
+ seq_id=2,
+ )
+
+ assert uninitialized["code"] == 1
+ assert uninitialized["message"] == "Tool is not initialized"
+ assert missing["code"] == 1
+ assert missing["message"] == "Tool missing not found"
+
+
+async def test_plugin_runtime_handler_shutdown_schedules_callback():
+ handler, _initialized = _handler()
+ called = asyncio.Event()
+
+ async def shutdown():
+ called.set()
+
+ handler.shutdown_callback = shutdown
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(RuntimeToPluginAction.SHUTDOWN.value)
+ await asyncio.wait_for(called.wait(), timeout=1)
+
+ assert response["data"] == {}
diff --git a/tests/cli/test_buildplugin.py b/tests/cli/test_buildplugin.py
new file mode 100644
index 00000000..6f1cfe50
--- /dev/null
+++ b/tests/cli/test_buildplugin.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import os
+import zipfile
+
+import yaml
+
+from langbot_plugin.cli.commands import buildplugin
+
+
+def test_parse_gitignore_ignores_comments_and_blank_lines(tmp_path):
+ gitignore = tmp_path / ".gitignore"
+ gitignore.write_text(
+ "\n# cache\n.env\nbuild/\n*.pyc\n",
+ encoding="utf-8",
+ )
+
+ assert buildplugin.parse_gitignore(str(gitignore)) == [".env", "build/", "*.pyc"]
+
+
+def test_should_ignore_supports_directory_root_wildcard_and_exact_patterns():
+ patterns = ["build/", "/dist", "*.pyc", "secret.txt"]
+
+ assert buildplugin.should_ignore("build/output.txt", patterns) is True
+ assert buildplugin.should_ignore("dist/plugin.zip", patterns) is True
+ assert buildplugin.should_ignore("pkg/module.pyc", patterns) is True
+ assert buildplugin.should_ignore("nested/secret.txt", patterns) is True
+ assert buildplugin.should_ignore("src/main.py", patterns) is False
+
+
+def test_build_plugin_process_packages_manifest_and_filters_ignored_files(
+ tmp_path, monkeypatch
+):
+ plugin_dir = tmp_path / "plugin"
+ plugin_dir.mkdir()
+ output_dir = tmp_path / "dist"
+ (plugin_dir / "manifest.yaml").write_text(
+ yaml.safe_dump(
+ {
+ "apiVersion": "v1",
+ "kind": "Plugin",
+ "metadata": {
+ "name": "demo",
+ "label": {"en_US": "Demo"},
+ "author": "tester",
+ "version": "0.1.0",
+ },
+ "spec": {"components": {}},
+ },
+ sort_keys=False,
+ ),
+ encoding="utf-8",
+ )
+ (plugin_dir / "main.py").write_text("print('hello')\n", encoding="utf-8")
+ (plugin_dir / ".env").write_text("SECRET=1\n", encoding="utf-8")
+ (plugin_dir / ".gitignore").write_text("ignored.txt\ncache/\n", encoding="utf-8")
+ (plugin_dir / "ignored.txt").write_text("ignore", encoding="utf-8")
+ cache_dir = plugin_dir / "cache"
+ cache_dir.mkdir()
+ (cache_dir / "data.txt").write_text("ignore", encoding="utf-8")
+ monkeypatch.chdir(plugin_dir)
+ monkeypatch.setattr(buildplugin, "cli_print", lambda *args, **kwargs: None)
+
+ package_path = buildplugin.build_plugin_process(str(output_dir))
+
+ assert package_path == os.path.join(
+ str(output_dir), "tester-demo-0.1.0.lbpkg"
+ )
+ with zipfile.ZipFile(package_path) as package:
+ names = set(package.namelist())
+ assert {"manifest.yaml", "main.py", ".gitignore"} <= names
+ assert ".env" not in names
+ assert "ignored.txt" not in names
+ assert "cache/data.txt" not in names
+
+
+def test_build_plugin_process_returns_none_when_manifest_missing(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(buildplugin, "cli_print", lambda *args, **kwargs: None)
+
+ assert buildplugin.build_plugin_process(str(tmp_path / "dist")) is None
diff --git a/tests/cli/test_gencomponent.py b/tests/cli/test_gencomponent.py
new file mode 100644
index 00000000..2bfb42d2
--- /dev/null
+++ b/tests/cli/test_gencomponent.py
@@ -0,0 +1,90 @@
+from __future__ import annotations
+
+import yaml
+
+from langbot_plugin.cli.commands import gencomponent
+
+
+def _write_plugin_manifest(path):
+ (path / "manifest.yaml").write_text(
+ yaml.safe_dump(
+ {
+ "apiVersion": "v1",
+ "kind": "Plugin",
+ "metadata": {
+ "author": "tester",
+ "name": "demo",
+ "label": {"en_US": "Demo"},
+ "version": "0.1.0",
+ },
+ "spec": {"components": {}},
+ "execution": {"python": {"path": "main.py", "attr": "Demo"}},
+ },
+ sort_keys=False,
+ ),
+ encoding="utf-8",
+ )
+
+
+def test_generate_component_requires_plugin_root(tmp_path, monkeypatch, capsys):
+ monkeypatch.chdir(tmp_path)
+
+ gencomponent.generate_component_process("Command")
+
+ assert "!!" in capsys.readouterr().out
+
+
+def test_generate_component_reports_unknown_component(tmp_path, monkeypatch, capsys):
+ _write_plugin_manifest(tmp_path)
+ monkeypatch.chdir(tmp_path)
+
+ gencomponent.generate_component_process("Nope")
+
+ output = capsys.readouterr().out
+ assert "!!" in output
+ assert "Command" in output
+
+
+def test_generate_command_component_creates_files_and_updates_manifest(
+ tmp_path, monkeypatch
+):
+ _write_plugin_manifest(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(gencomponent, "cli_print", lambda *args: None)
+ monkeypatch.setattr(
+ gencomponent,
+ "input_form_values",
+ lambda fields: {
+ "cmd_name": "hello",
+ "cmd_description": "Say hello",
+ },
+ )
+
+ gencomponent.generate_component_process("Command")
+
+ assert (tmp_path / "components" / "__init__.py").is_file()
+ assert (tmp_path / "components" / "commands" / "__init__.py").is_file()
+ assert (tmp_path / "components" / "commands" / "hello.yaml").is_file()
+ assert (tmp_path / "components" / "commands" / "hello.py").is_file()
+ manifest = yaml.safe_load((tmp_path / "manifest.yaml").read_text())
+ assert manifest["spec"]["components"]["Command"] == {
+ "fromDirs": [{"path": "components/commands/"}]
+ }
+
+
+def test_generate_page_component_skips_python_package_init_files(tmp_path, monkeypatch):
+ _write_plugin_manifest(tmp_path)
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(gencomponent, "cli_print", lambda *args: None)
+ monkeypatch.setattr(
+ gencomponent,
+ "input_form_values",
+ lambda fields: {"page_name": "settings"},
+ )
+
+ gencomponent.generate_component_process("Page")
+
+ assert not (tmp_path / "components" / "__init__.py").exists()
+ assert not (tmp_path / "components" / "pages" / "__init__.py").exists()
+ assert (tmp_path / "components" / "pages" / "settings.yaml").is_file()
+ assert (tmp_path / "components" / "pages" / "settings.html").is_file()
diff --git a/tests/cli/test_i18n_form.py b/tests/cli/test_i18n_form.py
new file mode 100644
index 00000000..a1d4ec95
--- /dev/null
+++ b/tests/cli/test_i18n_form.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+import builtins
+
+from langbot_plugin.cli import i18n
+from langbot_plugin.cli.utils import form
+
+
+def test_i18n_manager_detects_locale_from_environment(monkeypatch):
+ monkeypatch.setenv("LC_ALL", "zh_CN.UTF-8")
+
+ assert i18n.I18nManager().get_current_locale() == "zh_Hans"
+
+ monkeypatch.setenv("LC_ALL", "es_ES.UTF-8")
+ assert i18n.I18nManager().get_current_locale() == "es_ES"
+
+
+def test_set_locale_ignores_unsupported_locale_and_translate_falls_back_to_key():
+ original = i18n.get_current_locale()
+ try:
+ i18n.set_locale("en_US")
+ assert i18n.get_current_locale() == "en_US"
+ assert i18n.t("missing_key") == "missing_key"
+ i18n.set_locale("not_real")
+ assert i18n.get_current_locale() == "en_US"
+ finally:
+ i18n.set_locale(original)
+
+
+def test_extract_i18n_label_uses_current_locale_then_english_fallback():
+ original = i18n.get_current_locale()
+ try:
+ i18n.set_locale("zh_Hans")
+ assert i18n.extract_i18n_label({"en_US": "Name", "zh_Hans": "名称"}) == "名称"
+ assert i18n.extract_i18n_label({"en_US": "Name"}) == "Name"
+ finally:
+ i18n.set_locale(original)
+
+
+def test_input_form_values_retries_required_invalid_value(monkeypatch, capsys):
+ answers = iter(["Bad Value", "good_name", "description"])
+ monkeypatch.setattr(builtins, "input", lambda _prompt: next(answers))
+ fields = [
+ {
+ "name": "tool_name",
+ "label": {"en_US": "Tool name"},
+ "required": True,
+ "format": {
+ "regexp": form.NUMBER_LOWER_UNDERSCORE_REGEXP,
+ "error": {"en_US": "Bad tool name"},
+ },
+ },
+ {
+ "name": "description",
+ "label": {"en_US": "Description"},
+ "required": False,
+ },
+ ]
+
+ values = form.input_form_values(fields)
+
+ assert values == {"tool_name": "good_name", "description": "description"}
+ assert "Bad tool name" in capsys.readouterr().out
diff --git a/tests/cli/test_initplugin.py b/tests/cli/test_initplugin.py
new file mode 100644
index 00000000..5fed614f
--- /dev/null
+++ b/tests/cli/test_initplugin.py
@@ -0,0 +1,102 @@
+from __future__ import annotations
+
+import os
+import subprocess
+
+import yaml
+
+from langbot_plugin.cli.commands import initplugin
+
+
+def test_get_lbp_path_uses_platform_specific_script_location(monkeypatch):
+ monkeypatch.setattr(initplugin.sys, "executable", "/opt/python/bin/python")
+ monkeypatch.setattr(initplugin.platform, "system", lambda: "Linux")
+ assert initplugin.get_lbp_path() == "/opt/python/bin/lbp"
+
+ monkeypatch.setattr(initplugin.sys, "executable", r"C:\Python\python.exe")
+ monkeypatch.setattr(initplugin.platform, "system", lambda: "Windows")
+ assert initplugin.get_lbp_path().endswith(os.path.join("Scripts", "lbp.exe"))
+
+
+def test_is_git_available_returns_false_when_git_missing(monkeypatch):
+ def raise_missing(*args, **kwargs):
+ raise FileNotFoundError
+
+ monkeypatch.setattr(initplugin.subprocess, "run", raise_missing)
+
+ assert initplugin.is_git_available() is False
+
+
+def test_init_git_repo_invokes_git_init(monkeypatch):
+ calls = []
+ prints = []
+ monkeypatch.setattr(
+ initplugin.subprocess,
+ "run",
+ lambda cmd, **kwargs: calls.append((cmd, kwargs)),
+ )
+ monkeypatch.setattr(initplugin, "cli_print", lambda *args: prints.append(args))
+
+ initplugin.init_git_repo("plugin")
+
+ assert calls[0][0] == ["git", "init"]
+ assert calls[0][1]["cwd"] == "plugin"
+ assert prints == [("git_repo_initialized", "plugin")]
+
+
+def test_init_git_repo_reports_git_warning(monkeypatch):
+ error = subprocess.CalledProcessError(1, ["git"], stderr=b"boom")
+ prints = []
+ monkeypatch.setattr(initplugin.subprocess, "run", lambda *args, **kwargs: (_ for _ in ()).throw(error))
+ monkeypatch.setattr(initplugin, "cli_print", lambda *args: prints.append(args))
+
+ initplugin.init_git_repo("plugin")
+
+ assert prints == [("git_init_warning", b"boom")]
+
+
+def test_init_plugin_process_generates_plugin_scaffold(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(
+ initplugin,
+ "input_form_values",
+ lambda fields: {
+ "plugin_author": "tester",
+ "plugin_description": "Demo plugin",
+ },
+ )
+ monkeypatch.setattr(initplugin, "is_git_available", lambda: False)
+ monkeypatch.setattr(initplugin, "cli_print", lambda *args: None)
+ monkeypatch.setattr(initplugin, "get_lbp_path", lambda: "/usr/bin/lbp")
+
+ initplugin.init_plugin_process("demo-plugin")
+
+ plugin_dir = tmp_path / "demo-plugin"
+ assert (plugin_dir / "manifest.yaml").is_file()
+ assert (plugin_dir / "main.py").is_file()
+ assert (plugin_dir / "assets" / "icon.svg").is_file()
+ assert (plugin_dir / ".vscode" / "launch.json").is_file()
+ manifest = yaml.safe_load((plugin_dir / "manifest.yaml").read_text())
+ assert manifest["metadata"]["author"] == "tester"
+ assert manifest["metadata"]["name"] == "demo-plugin"
+ assert manifest["execution"]["python"]["attr"] == "demoplugin"
+
+
+def test_init_plugin_process_rejects_invalid_name(tmp_path, monkeypatch, capsys):
+ monkeypatch.chdir(tmp_path)
+
+ initplugin.init_plugin_process("bad name")
+
+ assert "!!" in capsys.readouterr().out
+ assert not (tmp_path / "bad name").exists()
+
+
+def test_init_plugin_process_rejects_non_empty_existing_directory(tmp_path, monkeypatch, capsys):
+ plugin_dir = tmp_path / "demo"
+ plugin_dir.mkdir()
+ (plugin_dir / "main.py").write_text("existing", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+
+ initplugin.init_plugin_process("demo")
+
+ assert "!!" in capsys.readouterr().out
diff --git a/tests/cli/test_login.py b/tests/cli/test_login.py
new file mode 100644
index 00000000..414e755b
--- /dev/null
+++ b/tests/cli/test_login.py
@@ -0,0 +1,516 @@
+from __future__ import annotations
+
+import json
+import time
+
+import httpx
+
+from langbot_plugin.cli.commands import login
+
+
+class FakeResponse:
+ def __init__(self, json_data, *, raise_error=False):
+ self._json_data = json_data
+ self.raise_error = raise_error
+
+ def raise_for_status(self):
+ if self.raise_error:
+ raise httpx.HTTPStatusError("bad", request=None, response=None)
+
+ def json(self):
+ return self._json_data
+
+
+class FakeClient:
+ responses: list[FakeResponse] = []
+ calls: list[tuple[str, dict]] = []
+
+ def __init__(self, timeout):
+ self.timeout = timeout
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def post(self, url, **kwargs):
+ self.calls.append((url, kwargs))
+ return self.responses.pop(0)
+
+
+class FailingClient:
+ def __init__(self, timeout):
+ self.timeout = timeout
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def post(self, url, **kwargs):
+ raise httpx.RequestError("network down")
+
+
+class ErrorClient:
+ def __init__(self, timeout):
+ self.timeout = timeout
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def post(self, url, **kwargs):
+ raise RuntimeError("boom")
+
+
+def test_save_config_creates_nested_server_config(tmp_path, monkeypatch):
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+
+ path = login._save_config({"access_token": "token"})
+
+ assert path == str(tmp_path / ".langbot" / "cli" / "config.json")
+ assert json.loads((tmp_path / ".langbot" / "cli" / "config.json").read_text()) == {
+ "https://cloud": {"access_token": "token"}
+ }
+
+
+def test_save_config_migrates_old_flat_config(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ config_file.write_text(json.dumps({"access_token": "old"}), encoding="utf-8")
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+
+ login._save_config({"access_token": "new"})
+
+ assert json.loads(config_file.read_text()) == {
+ "https://cloud": {"access_token": "new"}
+ }
+
+
+def test_save_config_recovers_from_corrupt_existing_config(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ config_file.write_text("{bad json", encoding="utf-8")
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+
+ login._save_config({"access_token": "new"})
+
+ assert json.loads(config_file.read_text()) == {
+ "https://cloud": {"access_token": "new"}
+ }
+
+
+def test_load_config_supports_flat_and_nested_config(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+
+ config_file.write_text(json.dumps({"access_token": "flat"}), encoding="utf-8")
+ assert login._load_config() == {"access_token": "flat"}
+
+ config_file.write_text(
+ json.dumps({"https://cloud": {"access_token": "nested"}}),
+ encoding="utf-8",
+ )
+ assert login._load_config() == {"access_token": "nested"}
+
+
+def test_load_config_returns_none_for_missing_or_corrupt_config(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+
+ assert login._load_config() is None
+
+ config_file.write_text("{bad json", encoding="utf-8")
+ assert login._load_config() is None
+
+
+def test_is_token_valid_accepts_pat_and_unexpired_oauth_token(monkeypatch):
+ monkeypatch.setattr(login.time, "time", lambda: 200)
+
+ assert login._is_token_valid({"token_type": "personal_access_token"}) is True
+ assert login._is_token_valid({"login_time": 100, "expires_in": 200}) is True
+ assert login._is_token_valid({"login_time": 100, "expires_in": 50}) is False
+ assert login._is_token_valid({}) is False
+
+
+def test_refresh_token_posts_refresh_token_and_persists_new_access_token(
+ tmp_path, monkeypatch
+):
+ FakeClient.calls = []
+ FakeClient.responses = [
+ FakeResponse({"data": {"access_token": "new-token", "expires_in": 3600}})
+ ]
+ monkeypatch.setattr(login.httpx, "Client", FakeClient)
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(login.time, "time", lambda: 1234)
+
+ config = {"refresh_token": "refresh-token"}
+
+ assert login._refresh_token(config) is True
+ assert config == {
+ "refresh_token": "refresh-token",
+ "access_token": "new-token",
+ "expires_in": 3600,
+ "login_time": 1234,
+ }
+ assert FakeClient.calls == [
+ (
+ "https://cloud/api/v1/accounts/token/refresh",
+ {"json": {"refresh_token": "refresh-token"}},
+ )
+ ]
+
+
+def test_refresh_token_returns_false_without_refresh_token():
+ assert login._refresh_token({}) is False
+ assert login._refresh_token({"access_token": "token"}) is False
+
+
+def test_refresh_token_returns_false_when_response_lacks_access_token(monkeypatch):
+ FakeClient.calls = []
+ FakeClient.responses = [FakeResponse({"data": {"expires_in": 3600}})]
+ monkeypatch.setattr(login.httpx, "Client", FakeClient)
+
+ assert login._refresh_token({"refresh_token": "refresh-token"}) is False
+
+
+def test_refresh_token_reports_unexpected_failure(monkeypatch):
+ prints = []
+ monkeypatch.setattr(login.httpx, "Client", ErrorClient)
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+
+ assert login._refresh_token({"refresh_token": "refresh-token"}) is False
+ assert len(prints) == 1
+ assert prints[0][0] == "token_refresh_failed"
+
+
+def test_generate_device_code_posts_to_token_generate(monkeypatch):
+ FakeClient.calls = []
+ FakeClient.responses = [FakeResponse({"code": 0, "data": {"device_code": "d"}})]
+ monkeypatch.setattr(login.httpx, "Client", FakeClient)
+
+ assert login._generate_device_code("https://cloud/api/v1") == {
+ "code": 0,
+ "data": {"device_code": "d"},
+ }
+ assert FakeClient.calls == [
+ ("https://cloud/api/v1/accounts/token/generate", {})
+ ]
+
+
+def test_generate_device_code_reports_network_failure(monkeypatch):
+ monkeypatch.setattr(login.httpx, "Client", FailingClient)
+ monkeypatch.setattr(login, "t", lambda key, error: f"{key}: {error}")
+
+ assert login._generate_device_code("https://cloud/api/v1") == {
+ "code": -1,
+ "msg": "network_request_failed: network down",
+ }
+
+
+def test_generate_device_code_reports_unexpected_failure(monkeypatch):
+ monkeypatch.setattr(login.httpx, "Client", ErrorClient)
+ monkeypatch.setattr(login, "t", lambda key, error: f"{key}: {error}")
+
+ assert login._generate_device_code("https://cloud/api/v1") == {
+ "code": -1,
+ "msg": "device_code_failed: boom",
+ }
+
+
+def test_poll_for_token_returns_token_data(monkeypatch):
+ FakeClient.calls = []
+ FakeClient.responses = [
+ FakeResponse({"code": 0, "data": {"access_token": "token"}})
+ ]
+ monkeypatch.setattr(login.httpx, "Client", FakeClient)
+ monkeypatch.setattr(login.time, "time", lambda: 0)
+
+ assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) == {
+ "access_token": "token"
+ }
+ assert FakeClient.calls == [
+ (
+ "https://cloud/api/v1/accounts/token/get",
+ {"json": {"device_code": "dev", "user_code": "user"}},
+ )
+ ]
+
+
+def test_poll_for_token_waits_while_authorization_is_pending(monkeypatch):
+ sleeps = []
+ times = iter([0, 0, 1])
+ FakeClient.calls = []
+ FakeClient.responses = [
+ FakeResponse({"code": 425, "msg": "pending"}),
+ FakeResponse({"code": 0, "data": {"access_token": "token"}}),
+ ]
+ monkeypatch.setattr(login.httpx, "Client", FakeClient)
+ monkeypatch.setattr(login.time, "time", lambda: next(times))
+ monkeypatch.setattr(login.time, "sleep", lambda seconds: sleeps.append(seconds))
+
+ assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) == {
+ "access_token": "token"
+ }
+ assert sleeps == [3]
+
+
+def test_poll_for_token_reports_non_pending_api_failure(monkeypatch):
+ prints = []
+ FakeClient.calls = []
+ FakeClient.responses = [FakeResponse({"code": 400, "msg": "bad code"})]
+ monkeypatch.setattr(login.httpx, "Client", FakeClient)
+ monkeypatch.setattr(login.time, "time", lambda: 0)
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+
+ assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) is None
+ assert prints == [("token_get_failed", "bad code")]
+
+
+def test_poll_for_token_reports_network_failure(monkeypatch):
+ prints = []
+ monkeypatch.setattr(login.httpx, "Client", FailingClient)
+ monkeypatch.setattr(login.time, "time", lambda: 0)
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+
+ assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) is None
+ assert len(prints) == 1
+ assert prints[0][0] == "network_request_failed"
+
+
+def test_poll_for_token_reports_unexpected_failure(monkeypatch):
+ prints = []
+ monkeypatch.setattr(login.httpx, "Client", ErrorClient)
+ monkeypatch.setattr(login.time, "time", lambda: 0)
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+
+ assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 10) is None
+ assert len(prints) == 1
+ assert prints[0][0] == "token_check_failed"
+
+
+def test_poll_for_token_returns_none_after_timeout(monkeypatch):
+ times = iter([0, 31])
+ monkeypatch.setattr(login.time, "time", lambda: next(times))
+
+ assert login._poll_for_token("https://cloud/api/v1", "dev", "user", 3, 0) is None
+
+
+def test_login_process_rejects_invalid_personal_access_token(monkeypatch):
+ prints = []
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+
+ login.login_process("bad-token")
+
+ assert prints == [("pat_invalid_format",)]
+
+
+def test_login_process_saves_personal_access_token(tmp_path, monkeypatch):
+ prints = []
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(login.time, "time", lambda: 1234)
+
+ login.login_process("lbpat_secret")
+
+ config_file = tmp_path / ".langbot" / "cli" / "config.json"
+ assert json.loads(config_file.read_text())["https://cloud"] == {
+ "access_token": "lbpat_secret",
+ "token_type": "personal_access_token",
+ "login_time": 1234,
+ "expires_in": 0,
+ }
+ assert ("pat_login_successful",) in prints
+ assert ("pat_saved", str(config_file)) in prints
+
+
+def test_login_process_reports_device_code_failure(monkeypatch):
+ prints = []
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(
+ login,
+ "_generate_device_code",
+ lambda api_base: {"code": 1, "msg": "denied"},
+ )
+
+ login.login_process()
+
+ assert prints == [
+ ("starting_login",),
+ ("generating_device_code",),
+ ("device_code_failed", "denied"),
+ ]
+
+
+def test_login_process_reports_timeout_when_poll_returns_no_token(monkeypatch):
+ prints = []
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(
+ login,
+ "_generate_device_code",
+ lambda api_base: {
+ "code": 0,
+ "data": {
+ "device_code": "dev",
+ "user_code": "user",
+ "verification_uri": "/verify",
+ "expires_in": 60,
+ },
+ },
+ )
+ monkeypatch.setattr(
+ login,
+ "_poll_for_token",
+ lambda api_base, device_code, user_code, interval, expires_in: None,
+ )
+
+ login.login_process()
+
+ assert prints[-1] == ("login_timeout",)
+
+
+def test_login_process_saves_device_flow_token(monkeypatch):
+ prints = []
+ saved_configs = []
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(login.time, "time", lambda: 1234)
+ monkeypatch.setattr(
+ login,
+ "_generate_device_code",
+ lambda api_base: {
+ "code": 0,
+ "data": {
+ "device_code": "dev",
+ "user_code": "user",
+ "verification_uri": "/verify",
+ "expires_in": 60,
+ },
+ },
+ )
+ monkeypatch.setattr(
+ login,
+ "_poll_for_token",
+ lambda api_base, device_code, user_code, interval, expires_in: {
+ "access_token": "access",
+ "refresh_token": "refresh",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ },
+ )
+ monkeypatch.setattr(
+ login,
+ "_save_config",
+ lambda config: saved_configs.append(config) or "/tmp/config.json",
+ )
+
+ login.login_process()
+
+ assert saved_configs == [
+ {
+ "access_token": "access",
+ "refresh_token": "refresh",
+ "expires_in": 3600,
+ "token_type": "Bearer",
+ "login_time": 1234,
+ }
+ ]
+ assert ("login_successful",) in prints
+ assert ("token_saved", "/tmp/config.json") in prints
+ assert ("token_type_label", "Bearer") in prints
+ assert ("expires_in_label", 3600) in prints
+
+
+def test_login_process_reports_unexpected_error(monkeypatch):
+ prints = []
+ error = RuntimeError("boom")
+ monkeypatch.setattr(login, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(
+ login,
+ "_generate_device_code",
+ lambda api_base: (_ for _ in ()).throw(error),
+ )
+
+ login.login_process()
+
+ assert prints == [
+ ("starting_login",),
+ ("generating_device_code",),
+ ("login_error", error),
+ ]
+
+
+def test_check_login_status_refreshes_expired_token(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ (config_dir / "config.json").write_text(
+ json.dumps(
+ {
+ "https://cloud": {
+ "refresh_token": "refresh-token",
+ "login_time": int(time.time()) - 100,
+ "expires_in": 1,
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(login, "_refresh_token", lambda config: True)
+
+ assert login.check_login_status() is True
+
+
+def test_get_access_token_returns_only_valid_token(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ monkeypatch.setattr(login.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(login, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(login.time, "time", lambda: 200)
+
+ config_file.write_text(
+ json.dumps(
+ {
+ "https://cloud": {
+ "access_token": "token",
+ "login_time": 100,
+ "expires_in": 200,
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ assert login.get_access_token() == "token"
+
+ config_file.write_text(
+ json.dumps(
+ {
+ "https://cloud": {
+ "access_token": "expired",
+ "login_time": 100,
+ "expires_in": 50,
+ }
+ }
+ ),
+ encoding="utf-8",
+ )
+ assert login.get_access_token() is None
diff --git a/tests/cli/test_logout_publish.py b/tests/cli/test_logout_publish.py
new file mode 100644
index 00000000..861068c4
--- /dev/null
+++ b/tests/cli/test_logout_publish.py
@@ -0,0 +1,184 @@
+from __future__ import annotations
+
+import json
+
+import httpx
+
+from langbot_plugin.cli.commands import logout, publish
+
+
+def test_logout_process_reports_already_logged_out_when_config_missing(
+ tmp_path, monkeypatch
+):
+ prints = []
+ monkeypatch.setattr(logout.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args))
+
+ logout.logout_process()
+
+ assert prints == [("already_logged_out",)]
+
+
+def test_logout_process_removes_old_flat_token_config(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ config_file.write_text(json.dumps({"access_token": "token"}), encoding="utf-8")
+ prints = []
+ monkeypatch.setattr(logout.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args))
+
+ logout.logout_process()
+
+ assert not config_file.exists()
+ assert prints[0] == ("logout_successful",)
+
+
+def test_logout_process_removes_current_server_from_nested_config(tmp_path, monkeypatch):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ config_file.write_text(
+ json.dumps(
+ {
+ "https://cloud": {"access_token": "token"},
+ "https://other": {"access_token": "other"},
+ }
+ ),
+ encoding="utf-8",
+ )
+ prints = []
+ monkeypatch.setattr(logout.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(logout, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args))
+
+ logout.logout_process()
+
+ assert json.loads(config_file.read_text()) == {
+ "https://other": {"access_token": "other"}
+ }
+ assert prints == [("logout_successful",)]
+
+
+def test_logout_process_removes_file_when_last_nested_credential_is_deleted(
+ tmp_path, monkeypatch
+):
+ config_dir = tmp_path / ".langbot" / "cli"
+ config_dir.mkdir(parents=True)
+ config_file = config_dir / "config.json"
+ config_file.write_text(
+ json.dumps({"https://cloud": {"access_token": "token"}}),
+ encoding="utf-8",
+ )
+ prints = []
+ monkeypatch.setattr(logout.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(logout, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(logout, "cli_print", lambda *args: prints.append(args))
+
+ logout.logout_process()
+
+ assert not config_file.exists()
+ assert prints[0] == ("logout_successful",)
+
+
+class FakeResponse:
+ def __init__(self, json_data, *, raise_error=False):
+ self._json_data = json_data
+ self.raise_error = raise_error
+
+ def raise_for_status(self):
+ if self.raise_error:
+ raise httpx.HTTPStatusError("bad", request=None, response=None)
+
+ def json(self):
+ return self._json_data
+
+
+class FakeClient:
+ calls = []
+ response = FakeResponse({"code": 0, "data": {"submission": {"status": "live"}}})
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ def post(self, url, files, data, headers, timeout):
+ self.calls.append(
+ {
+ "url": url,
+ "file_name": files["file"].name,
+ "data": data,
+ "headers": headers,
+ "timeout": timeout,
+ }
+ )
+ return self.response
+
+
+def test_publish_plugin_posts_package_with_bearer_token(tmp_path, monkeypatch):
+ package = tmp_path / "plugin.lbpkg"
+ package.write_bytes(b"package")
+ prints = []
+ FakeClient.calls = []
+ FakeClient.response = FakeResponse(
+ {"code": 0, "data": {"submission": {"status": "live"}}}
+ )
+ monkeypatch.setattr(publish.httpx, "Client", FakeClient)
+ monkeypatch.setattr(publish, "SERVER_URL", "https://cloud")
+ monkeypatch.setattr(publish, "cli_print", lambda *args: prints.append(args))
+
+ publish.publish_plugin(str(package), "change", "token")
+
+ assert FakeClient.calls == [
+ {
+ "url": "https://cloud/api/v1/marketplace/plugins/publish",
+ "file_name": str(package),
+ "data": {"changelog": "change"},
+ "headers": {"Authorization": "Bearer token"},
+ "timeout": 300,
+ }
+ ]
+ assert prints == [("publish_successful", "https://cloud")]
+
+
+def test_publish_plugin_reports_api_failure(tmp_path, monkeypatch):
+ package = tmp_path / "plugin.lbpkg"
+ package.write_bytes(b"package")
+ prints = []
+ FakeClient.calls = []
+ FakeClient.response = FakeResponse({"code": 1, "msg": "nope"})
+ monkeypatch.setattr(publish.httpx, "Client", FakeClient)
+ monkeypatch.setattr(publish, "cli_print", lambda *args: prints.append(args))
+
+ publish.publish_plugin(str(package), "", "token")
+
+ assert prints == [("publish_failed", "nope")]
+
+
+def test_publish_process_requires_login(monkeypatch):
+ prints = []
+ monkeypatch.setattr(publish, "check_login_status", lambda: False)
+ monkeypatch.setattr(publish, "cli_print", lambda *args: prints.append(args))
+
+ publish.publish_process()
+
+ assert prints == [("not_logged_in",)]
+
+
+def test_publish_process_builds_publishes_and_cleans_tmp_dir(monkeypatch):
+ calls = []
+ monkeypatch.setattr(publish, "check_login_status", lambda: True)
+ monkeypatch.setattr(publish, "get_access_token", lambda: "token")
+ monkeypatch.setattr(publish, "build_plugin_process", lambda output_dir: calls.append(("build", output_dir)) or "pkg")
+ monkeypatch.setattr(publish, "publish_plugin", lambda path, changelog, token: calls.append(("publish", path, changelog, token)))
+ monkeypatch.setattr(publish.shutil, "rmtree", lambda path: calls.append(("rmtree", path)))
+
+ publish.publish_process()
+
+ assert calls == [
+ ("build", publish.TMP_DIR),
+ ("publish", "pkg", "", "token"),
+ ("rmtree", publish.TMP_DIR),
+ ]
diff --git a/tests/cli/test_page_components.py b/tests/cli/test_page_components.py
new file mode 100644
index 00000000..7d25e91a
--- /dev/null
+++ b/tests/cli/test_page_components.py
@@ -0,0 +1,63 @@
+from __future__ import annotations
+
+from langbot_plugin.api.definition.components.manifest import ComponentManifest
+from langbot_plugin.cli.utils.page_components import populate_plugin_pages
+
+
+def _manifest(kind: str, name: str, rel_path: str, spec=None) -> ComponentManifest:
+ return ComponentManifest(
+ owner="tester",
+ manifest={
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": {
+ "name": name,
+ "label": {"en_US": name.title()},
+ },
+ "spec": spec or {},
+ },
+ rel_path=rel_path,
+ )
+
+
+def test_populate_plugin_pages_merges_existing_and_component_pages():
+ plugin = _manifest(
+ "Plugin",
+ "demo",
+ "manifest.yaml",
+ spec={
+ "components": {},
+ "pages": [{"id": "existing", "label": {"en_US": "Existing"}, "path": "x"}],
+ },
+ )
+ component = _manifest(
+ "Page",
+ "settings",
+ "components/pages/settings.yaml",
+ spec={"path": "settings.html"},
+ )
+
+ populate_plugin_pages(plugin, [component])
+
+ assert plugin.manifest["spec"]["pages"] == [
+ {"id": "existing", "label": {"en_US": "Existing"}, "path": "x"},
+ {
+ "id": "settings",
+ "label": {"en_US": "Settings"},
+ "path": "components/pages/settings.html",
+ },
+ ]
+
+
+def test_populate_plugin_pages_deduplicates_page_ids():
+ plugin = _manifest(
+ "Plugin",
+ "demo",
+ "manifest.yaml",
+ spec={"components": {}, "pages": [{"id": "settings", "path": "existing"}]},
+ )
+ component = _manifest("Page", "settings", "components/pages/settings.yaml")
+
+ populate_plugin_pages(plugin, [component])
+
+ assert plugin.manifest["spec"]["pages"] == [{"id": "settings", "path": "existing"}]
diff --git a/tests/cli/test_renderer.py b/tests/cli/test_renderer.py
new file mode 100644
index 00000000..72e76567
--- /dev/null
+++ b/tests/cli/test_renderer.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from langbot_plugin.cli.gen import renderer
+
+
+def test_component_input_post_processors_create_python_class_names():
+ assert renderer.tool_component_input_post_process(
+ {"tool_name": "web_search", "tool_description": "Search the web"}
+ ) == {
+ "tool_name": "web_search",
+ "tool_label": "WebSearch",
+ "tool_description": "Search the web",
+ "tool_attr": "WebSearch",
+ }
+ assert renderer.command_component_input_post_process(
+ {"cmd_name": "hello_world", "cmd_description": "Say hello"}
+ )["cmd_attr"] == "HelloWorld"
+ assert renderer.knowledge_engine_component_input_post_process(
+ {
+ "knowledge_engine_name": "local_docs",
+ "knowledge_engine_description": "Docs",
+ }
+ )["knowledge_engine_attr"] == "LocalDocs"
+ assert renderer.parser_component_input_post_process(
+ {"parser_name": "pdf_reader", "parser_description": "PDF"}
+ )["parser_label"] == "PdfReader"
+ assert renderer.page_component_input_post_process({"page_name": "settings_page"}) == {
+ "page_name": "settings_page",
+ "page_label": "SettingsPage",
+ }
+
+
+def test_component_type_registry_contains_expected_public_component_kinds():
+ by_name = {component.type_name: component for component in renderer.component_types}
+
+ assert set(by_name) == {
+ "EventListener",
+ "Tool",
+ "Command",
+ "KnowledgeEngine",
+ "Parser",
+ "Page",
+ }
+ assert by_name["Tool"].target_dir == "components/tools"
+ assert "{tool_name}.py" in by_name["Tool"].template_files
+ assert by_name["Page"].target_dir == "components/pages"
+
+
+def test_simple_render_uses_python_format_context():
+ assert renderer.simple_render("hello {name}", name="plugin") == "hello plugin"
+
+
+def test_render_template_loads_packaged_templates():
+ rendered = renderer.render_template(
+ "components/tools/{tool_name}.yaml.example",
+ tool_name="weather",
+ tool_label="Weather",
+ tool_description="Lookup weather",
+ tool_attr="Weather",
+ )
+
+ assert "name: weather" in rendered
+ assert "Weather" in rendered
+ assert "Lookup weather" in rendered
diff --git a/tests/cli/test_runplugin.py b/tests/cli/test_runplugin.py
new file mode 100644
index 00000000..52310f66
--- /dev/null
+++ b/tests/cli/test_runplugin.py
@@ -0,0 +1,257 @@
+from __future__ import annotations
+
+import asyncio
+
+from langbot_plugin.cli.commands import runplugin
+
+
+class FakeDiscoveryEngine:
+ manifest = object()
+ load_calls = []
+
+ def load_component_manifest(self, **kwargs):
+ self.load_calls.append(kwargs)
+ return self.manifest
+
+
+class FakeRuntimeController:
+ instances = []
+
+ def __init__(
+ self,
+ plugin_manifest,
+ component_manifests,
+ stdio,
+ ws_debug_url,
+ prod_mode,
+ ):
+ self.plugin_manifest = plugin_manifest
+ self.component_manifests = component_manifests
+ self.stdio = stdio
+ self.ws_debug_url = ws_debug_url
+ self.prod_mode = prod_mode
+ self.calls = []
+ self.instances.append(self)
+
+ async def run(self):
+ self.calls.append("run")
+
+ async def mount(self):
+ self.calls.append("mount")
+
+
+async def test_arun_plugin_process_reports_missing_manifest(tmp_path, monkeypatch):
+ prints = []
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args))
+
+ await runplugin.arun_plugin_process(stdio=True)
+
+ assert prints == [("manifest_not_found",)]
+
+
+async def test_arun_plugin_process_reports_manifest_load_failure(tmp_path, monkeypatch):
+ prints = []
+ (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(
+ runplugin,
+ "ComponentDiscoveryEngine",
+ lambda: type(
+ "MissingManifestDiscovery",
+ (),
+ {"load_component_manifest": lambda self, **kwargs: None},
+ )(),
+ )
+
+ await runplugin.arun_plugin_process(stdio=True)
+
+ assert prints == [("manifest_not_found",)]
+
+
+async def test_arun_plugin_process_requires_debug_url_for_websocket_mode(
+ tmp_path, monkeypatch
+):
+ prints = []
+ (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("DEBUG_RUNTIME_WS_URL", raising=False)
+ monkeypatch.delenv("RUNTIME_WS_URL", raising=False)
+ monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args))
+ monkeypatch.setattr(runplugin, "ComponentDiscoveryEngine", FakeDiscoveryEngine)
+
+ await runplugin.arun_plugin_process(stdio=False)
+
+ assert prints == [("debug_url_not_set",)]
+
+
+async def test_arun_plugin_process_builds_controller_and_sets_runtime_env(
+ tmp_path, monkeypatch
+):
+ calls = []
+ manifest = object()
+ components = [object()]
+ (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.delenv("PLUGIN_DEBUG_KEY", raising=False)
+ monkeypatch.delenv("LANGBOT_PLUGIN_PYPI_INDEX_URL", raising=False)
+ monkeypatch.delenv("LANGBOT_PLUGIN_PYPI_TRUSTED_HOST", raising=False)
+ monkeypatch.setattr(
+ runplugin.dotenv,
+ "load_dotenv",
+ lambda path: calls.append(("load_dotenv", path)),
+ )
+ monkeypatch.setattr(
+ runplugin,
+ "ComponentDiscoveryEngine",
+ lambda: type(
+ "Discovery",
+ (),
+ {
+ "load_component_manifest": (
+ lambda self, **kwargs: calls.append(("load_manifest", kwargs))
+ or manifest
+ )
+ },
+ )(),
+ )
+ monkeypatch.setattr(
+ runplugin,
+ "discover_plugin_components",
+ lambda plugin_manifest, engine: calls.append(
+ ("discover_components", plugin_manifest, engine)
+ )
+ or components,
+ )
+ monkeypatch.setattr(
+ runplugin,
+ "populate_plugin_pages",
+ lambda plugin_manifest, component_manifests: calls.append(
+ ("populate_pages", plugin_manifest, component_manifests)
+ ),
+ )
+ monkeypatch.setattr(runplugin, "PluginRuntimeController", FakeRuntimeController)
+
+ await runplugin.arun_plugin_process(
+ stdio=True,
+ prod_mode=True,
+ plugin_debug_key="debug-key",
+ pypi_index_url="https://mirror",
+ pypi_trusted_host="mirror",
+ )
+
+ controller = FakeRuntimeController.instances[-1]
+ assert calls[0] == ("load_dotenv", ".env")
+ assert calls[1] == (
+ "load_manifest",
+ {"path": "manifest.yaml", "owner": "builtin", "no_save": True},
+ )
+ assert calls[2][0] == "discover_components"
+ assert calls[3] == ("populate_pages", manifest, components)
+ assert controller.plugin_manifest is manifest
+ assert controller.component_manifests is components
+ assert controller.stdio is True
+ assert controller.ws_debug_url == ""
+ assert controller.prod_mode is True
+ assert controller.calls == ["mount", "run"]
+ assert runplugin.os.environ["PLUGIN_DEBUG_KEY"] == "debug-key"
+ assert runplugin.os.environ["LANGBOT_PLUGIN_PYPI_INDEX_URL"] == "https://mirror"
+ assert runplugin.os.environ["LANGBOT_PLUGIN_PYPI_TRUSTED_HOST"] == "mirror"
+
+
+async def test_arun_plugin_process_uses_debug_runtime_ws_url(tmp_path, monkeypatch):
+ components = []
+ manifest = object()
+ (tmp_path / "manifest.yaml").write_text("kind: Plugin\n", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.setenv("DEBUG_RUNTIME_WS_URL", "ws://debug")
+ monkeypatch.setenv("RUNTIME_WS_URL", "ws://runtime")
+ monkeypatch.setattr(
+ runplugin,
+ "ComponentDiscoveryEngine",
+ lambda: type(
+ "Discovery",
+ (),
+ {"load_component_manifest": lambda self, **kwargs: manifest},
+ )(),
+ )
+ monkeypatch.setattr(
+ runplugin,
+ "discover_plugin_components",
+ lambda plugin_manifest, engine: components,
+ )
+ monkeypatch.setattr(runplugin, "populate_plugin_pages", lambda *args: None)
+ monkeypatch.setattr(runplugin, "PluginRuntimeController", FakeRuntimeController)
+
+ await runplugin.arun_plugin_process(stdio=False)
+
+ assert FakeRuntimeController.instances[-1].ws_debug_url == "ws://debug"
+
+
+def test_run_plugin_process_configures_logging_and_runs_async_entry(monkeypatch):
+ calls = []
+ monkeypatch.setattr(
+ runplugin,
+ "configure_process_logging",
+ lambda: calls.append(("configure_logging",)),
+ )
+ monkeypatch.setattr(
+ runplugin,
+ "arun_plugin_process",
+ lambda *args: calls.append(("arun", args)) or "coroutine",
+ )
+ monkeypatch.setattr(
+ runplugin.asyncio,
+ "run",
+ lambda coroutine: calls.append(("asyncio_run", coroutine)),
+ )
+
+ runplugin.run_plugin_process(
+ stdio=True,
+ prod_mode=True,
+ plugin_debug_key="debug-key",
+ pypi_index_url="https://mirror",
+ pypi_trusted_host="mirror",
+ )
+
+ assert calls == [
+ ("configure_logging",),
+ (
+ "arun",
+ (True, True, "debug-key", "https://mirror", "mirror"),
+ ),
+ ("asyncio_run", "coroutine"),
+ ]
+
+
+def test_run_plugin_process_reports_cancelled_error(monkeypatch):
+ prints = []
+ monkeypatch.setattr(runplugin, "configure_process_logging", lambda: None)
+ monkeypatch.setattr(runplugin, "arun_plugin_process", lambda *args: "coroutine")
+ monkeypatch.setattr(
+ runplugin.asyncio,
+ "run",
+ lambda coroutine: (_ for _ in ()).throw(asyncio.CancelledError()),
+ )
+ monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args))
+
+ runplugin.run_plugin_process()
+
+ assert prints == [("plugin_process_cancelled",)]
+
+
+def test_run_plugin_process_reports_keyboard_interrupt(monkeypatch):
+ prints = []
+ monkeypatch.setattr(runplugin, "configure_process_logging", lambda: None)
+ monkeypatch.setattr(runplugin, "arun_plugin_process", lambda *args: "coroutine")
+ monkeypatch.setattr(
+ runplugin.asyncio,
+ "run",
+ lambda coroutine: (_ for _ in ()).throw(KeyboardInterrupt()),
+ )
+ monkeypatch.setattr(runplugin, "cli_print", lambda *args: prints.append(args))
+
+ runplugin.run_plugin_process()
+
+ assert prints == [("keyboard_interrupt",)]
diff --git a/tests/entities/io/test_protocol.py b/tests/entities/io/test_protocol.py
new file mode 100644
index 00000000..2d6011cc
--- /dev/null
+++ b/tests/entities/io/test_protocol.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+import pytest
+from pydantic import ValidationError
+
+from langbot_plugin.entities.io.actions.enums import (
+ CommonAction,
+ LangBotToRuntimeAction,
+ PluginToRuntimeAction,
+ RuntimeToLangBotAction,
+ RuntimeToPluginAction,
+)
+from langbot_plugin.entities.io.errors import (
+ ActionCallError,
+ ActionCallTimeoutError,
+ ConnectionClosedError,
+)
+from langbot_plugin.entities.io.req import ActionRequest
+from langbot_plugin.entities.io.resp import ActionResponse, ChunkStatus
+
+
+def test_action_request_factory_preserves_protocol_fields():
+ request = ActionRequest.make_request(
+ seq_id=42,
+ action=PluginToRuntimeAction.GET_BOT_UUID.value,
+ data={"query_id": 1001},
+ )
+
+ assert request.seq_id == 42
+ assert request.action == "get_bot_uuid"
+ assert request.data == {"query_id": 1001}
+ assert request.model_dump() == {
+ "seq_id": 42,
+ "action": "get_bot_uuid",
+ "data": {"query_id": 1001},
+ }
+
+
+def test_action_request_requires_mapping_data():
+ with pytest.raises(ValidationError):
+ ActionRequest(seq_id=1, action="ping", data=["not", "a", "dict"])
+
+
+def test_action_response_success_error_and_chunk_serialization():
+ success = ActionResponse.success({"ok": True})
+ assert success.seq_id == 0
+ assert success.code == 0
+ assert success.message == "success"
+ assert success.model_dump()["chunk_status"] == "continue"
+
+ error = ActionResponse.error("boom")
+ assert error.seq_id is None
+ assert error.code == 1
+ assert error.data == {}
+
+ end = ActionResponse(
+ seq_id=99,
+ code=0,
+ message="done",
+ data={},
+ chunk_status=ChunkStatus.END,
+ )
+ dumped = end.model_dump()
+ assert dumped["chunk_status"] == "end"
+ assert ActionResponse.model_validate(dumped).chunk_status is ChunkStatus.END
+
+
+def test_action_response_normalizes_missing_chunk_status_to_continue():
+ response = ActionResponse(seq_id=1, code=0, message="ok", data={}, chunk_status=None)
+ assert response.chunk_status is ChunkStatus.CONTINUE
+
+
+def test_protocol_error_messages_are_stable_strings():
+ assert str(ConnectionClosedError("closed")) == "closed"
+ assert str(ActionCallTimeoutError("slow")) == "slow"
+ assert str(ActionCallError("failed")) == "failed"
+
+
+def test_action_values_are_unique_inside_each_protocol_direction():
+ for action_group in (
+ CommonAction,
+ PluginToRuntimeAction,
+ RuntimeToPluginAction,
+ LangBotToRuntimeAction,
+ RuntimeToLangBotAction,
+ ):
+ values = [action.value for action in action_group]
+ assert len(values) == len(set(values)), action_group.__name__
diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/tests/helpers/__init__.py
@@ -0,0 +1 @@
+
diff --git a/tests/helpers/protocol.py b/tests/helpers/protocol.py
new file mode 100644
index 00000000..2a413f68
--- /dev/null
+++ b/tests/helpers/protocol.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from typing import Any
+
+from langbot_plugin.entities.io.errors import ConnectionClosedError
+from langbot_plugin.runtime.io.connection import Connection
+from langbot_plugin.runtime.io.handler import Handler
+
+
+class ProtocolConnection(Connection):
+ def __init__(self):
+ self.incoming: asyncio.Queue[str | BaseException] = asyncio.Queue()
+ self.sent: list[str] = []
+ self.sent_event = asyncio.Event()
+ self.closed = False
+
+ async def send(self, message: str) -> None:
+ self.sent.append(message)
+ self.sent_event.set()
+
+ async def receive(self) -> str:
+ message = await self.incoming.get()
+ if isinstance(message, BaseException):
+ raise message
+ return message
+
+ async def close(self) -> None:
+ self.closed = True
+
+ async def send_peer_request(
+ self,
+ action: str,
+ data: dict[str, Any] | None = None,
+ seq_id: int = 1,
+ ) -> None:
+ await self.incoming.put(
+ json.dumps({"seq_id": seq_id, "action": action, "data": data or {}})
+ )
+
+ async def send_peer_response(
+ self,
+ seq_id: int,
+ code: int = 0,
+ message: str = "success",
+ data: dict[str, Any] | None = None,
+ chunk_status: str = "continue",
+ ) -> None:
+ await self.incoming.put(
+ json.dumps(
+ {
+ "seq_id": seq_id,
+ "code": code,
+ "message": message,
+ "data": data or {},
+ "chunk_status": chunk_status,
+ }
+ )
+ )
+
+ async def close_peer(self) -> None:
+ await self.incoming.put(ConnectionClosedError("closed"))
+
+ async def sent_messages(self, count: int = 1) -> list[dict[str, Any]]:
+ for _ in range(50):
+ if len(self.sent) >= count:
+ return [json.loads(message) for message in self.sent[:count]]
+ await asyncio.sleep(0.01)
+ raise AssertionError(f"timed out waiting for {count} sent messages")
+
+
+class ProtocolSession:
+ def __init__(self, handler: Handler):
+ self.handler = handler
+ self.connection = handler.conn
+ assert isinstance(self.connection, ProtocolConnection)
+ self._task: asyncio.Task | None = None
+
+ async def __aenter__(self):
+ self._task = asyncio.create_task(self.handler.run())
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ await self.connection.close_peer()
+ if self._task is not None:
+ await self._task
+ return False
+
+ async def request(
+ self,
+ action: str,
+ data: dict[str, Any] | None = None,
+ seq_id: int = 1,
+ ) -> dict[str, Any]:
+ start = len(self.connection.sent)
+ await self.connection.send_peer_request(action, data, seq_id)
+ for _ in range(50):
+ if len(self.connection.sent) > start:
+ return json.loads(self.connection.sent[-1])
+ await asyncio.sleep(0.01)
+ raise AssertionError(f"timed out waiting for response to {action}")
+
+ async def request_messages(
+ self,
+ action: str,
+ data: dict[str, Any] | None = None,
+ seq_id: int = 1,
+ count: int = 1,
+ ) -> list[dict[str, Any]]:
+ start = len(self.connection.sent)
+ await self.connection.send_peer_request(action, data, seq_id)
+ for _ in range(50):
+ if len(self.connection.sent) >= start + count:
+ return [
+ json.loads(message)
+ for message in self.connection.sent[start : start + count]
+ ]
+ await asyncio.sleep(0.01)
+ raise AssertionError(f"timed out waiting for {count} responses to {action}")
diff --git a/tests/runtime/helper/test_marketplace.py b/tests/runtime/helper/test_marketplace.py
new file mode 100644
index 00000000..c39de1c6
--- /dev/null
+++ b/tests/runtime/helper/test_marketplace.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot_plugin.runtime.helper import marketplace
+
+
+def _plugin_payload():
+ return {
+ "created_at": "2025-08-10T21:29:28.54938+08:00",
+ "updated_at": "2025-08-11T14:17:19.223492+08:00",
+ "deleted_at": None,
+ "plugin_id": "tester/demo",
+ "author": "tester",
+ "name": "demo",
+ "label": {"en_US": "Demo"},
+ "description": {"en_US": "Demo plugin"},
+ "icon": "icon.svg",
+ "repository": "https://example.com/repo",
+ "tags": None,
+ "install_count": 1,
+ "latest_version": "0.1.0",
+ "status": "live",
+ }
+
+
+class FakeResponse:
+ def __init__(self, *, status_code=200, json_data=None, content=b""):
+ self.status_code = status_code
+ self._json_data = json_data or {}
+ self.content = content
+ self.text = "response text"
+ self.headers = {"content-length": str(len(content))}
+
+ def json(self):
+ return self._json_data
+
+ async def aiter_bytes(self, chunk_size=8192):
+ for i in range(0, len(self.content), chunk_size):
+ yield self.content[i : i + chunk_size]
+
+
+class FakeStream:
+ def __init__(self, response):
+ self.response = response
+
+ async def __aenter__(self):
+ return self.response
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+
+class FakeAsyncClient:
+ response = FakeResponse()
+ requests = []
+
+ def __init__(self, *args, **kwargs):
+ self.args = args
+ self.kwargs = kwargs
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return False
+
+ async def get(self, url):
+ self.requests.append(("GET", url))
+ return self.response
+
+ def stream(self, method, url):
+ self.requests.append((method, url))
+ return FakeStream(self.response)
+
+
+@pytest.fixture(autouse=True)
+def fake_client(monkeypatch):
+ FakeAsyncClient.requests = []
+ monkeypatch.setattr(marketplace.httpx, "AsyncClient", FakeAsyncClient)
+ monkeypatch.setattr(marketplace.runtime_settings, "cloud_service_url", "https://cloud")
+ return FakeAsyncClient
+
+
+@pytest.mark.asyncio
+async def test_get_plugin_info_validates_marketplace_response(fake_client):
+ fake_client.response = FakeResponse(
+ json_data={"code": 0, "data": {"plugin": _plugin_payload()}}
+ )
+
+ info = await marketplace.get_plugin_info("tester", "demo")
+
+ assert info.plugin_id == "tester/demo"
+ assert fake_client.requests == [
+ ("GET", "https://cloud/api/v1/marketplace/plugins/tester/demo")
+ ]
+
+
+@pytest.mark.asyncio
+async def test_download_plugin_returns_response_content(fake_client):
+ fake_client.response = FakeResponse(content=b"package")
+
+ assert await marketplace.download_plugin("tester", "demo", "0.1.0") == b"package"
+
+
+@pytest.mark.asyncio
+async def test_download_plugin_streaming_yields_progress_and_final_data(fake_client):
+ fake_client.response = FakeResponse(content=b"abcdef")
+
+ chunks = [
+ chunk
+ async for chunk in marketplace.download_plugin_streaming(
+ "tester", "demo", "0.1.0"
+ )
+ ]
+
+ assert chunks[0]["downloaded"] == 6
+ assert chunks[0]["done"] is False
+ assert chunks[-1]["done"] is True
+ assert chunks[-1]["data"] == b"abcdef"
+
+
+@pytest.mark.asyncio
+async def test_list_plugins_validates_each_plugin(fake_client):
+ fake_client.response = FakeResponse(
+ json_data={"code": 0, "data": {"plugins": [_plugin_payload()]}}
+ )
+
+ plugins = await marketplace.list_plugins()
+
+ assert [plugin.plugin_id for plugin in plugins] == ["tester/demo"]
diff --git a/tests/runtime/helper/test_pkgmgr.py b/tests/runtime/helper/test_pkgmgr.py
new file mode 100644
index 00000000..7abf90c2
--- /dev/null
+++ b/tests/runtime/helper/test_pkgmgr.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import asyncio
+
+from langbot_plugin.runtime.helper import pkgmgr
+
+
+def test_get_pip_index_args_defaults_to_official_pypi(monkeypatch):
+ monkeypatch.delenv(pkgmgr.PYPI_INDEX_URL_ENV, raising=False)
+ monkeypatch.delenv(pkgmgr.PYPI_TRUSTED_HOST_ENV, raising=False)
+
+ assert pkgmgr.get_pip_index_args() == ["-i", pkgmgr.DEFAULT_PYPI_INDEX_URL]
+
+
+def test_get_pip_index_args_reads_custom_index_and_trusted_hosts(monkeypatch):
+ monkeypatch.setenv(pkgmgr.PYPI_INDEX_URL_ENV, "https://mirror/simple")
+ monkeypatch.setenv(pkgmgr.PYPI_TRUSTED_HOST_ENV, "mirror.local, cache.local ")
+
+ assert pkgmgr.get_pip_index_args() == [
+ "-i",
+ "https://mirror/simple",
+ "--trusted-host",
+ "mirror.local",
+ "--trusted-host",
+ "cache.local",
+ ]
+
+
+def test_parse_requirements_ignores_comments_blank_lines_and_options(tmp_path):
+ requirements = tmp_path / "requirements.txt"
+ requirements.write_text(
+ "\n# comment\nrequests>=2\n-r base.txt\n--index-url https://mirror\npydantic\n",
+ encoding="utf-8",
+ )
+
+ assert pkgmgr.parse_requirements(str(requirements)) == ["requests>=2", "pydantic"]
+
+
+def test_parse_downloaded_bytes_supports_common_pip_units():
+ output = "\n".join(
+ [
+ "Downloading a.whl (1.5 kB)",
+ "Downloading b.whl (2 MB)",
+ "Downloading c.whl (3 bytes)",
+ ]
+ )
+
+ assert pkgmgr._parse_downloaded_bytes(output) == int(1.5 * 1024) + 2 * 1024 * 1024 + 3
+
+
+def test_install_single_builds_pip_command_and_returns_parsed_download_size(monkeypatch):
+ class Result:
+ returncode = 0
+ stdout = "Downloading pkg.whl (1 kB)"
+ stderr = ""
+
+ calls = []
+ monkeypatch.setattr(pkgmgr, "get_pip_index_args", lambda: ["-i", "https://mirror"])
+ monkeypatch.setattr(pkgmgr.subprocess, "run", lambda cmd, **kwargs: calls.append((cmd, kwargs)) or Result())
+
+ returncode, downloaded, output = pkgmgr.install_single("demo", ["--no-deps"])
+
+ assert returncode == 0
+ assert downloaded == 1024
+ assert "Downloading pkg.whl" in output
+ assert calls[0][0][-4:] == ["demo", "-i", "https://mirror", "--no-deps"]
+
+
+def test_install_requirements_passes_extra_params_to_pip(monkeypatch):
+ calls = []
+ monkeypatch.setattr(pkgmgr, "get_pip_index_args", lambda: ["-i", "https://mirror"])
+ monkeypatch.setattr(pkgmgr, "pipmain", lambda params: calls.append(params))
+
+ pkgmgr.install_requirements("requirements.txt", ["--no-deps"])
+
+ assert calls == [
+ [
+ "install",
+ "-r",
+ "requirements.txt",
+ "-i",
+ "https://mirror",
+ "--no-deps",
+ ]
+ ]
+
+
+def test_install_single_async_builds_pip_command_and_parses_output(monkeypatch):
+ class Proc:
+ returncode = 0
+
+ async def communicate(self):
+ return b"Downloading async.whl (2 kB)", b""
+
+ calls = []
+
+ async def fake_create_subprocess_exec(*cmd, stdout=None, stderr=None):
+ calls.append((cmd, stdout, stderr))
+ return Proc()
+
+ monkeypatch.setattr(pkgmgr, "get_pip_index_args", lambda: [])
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
+
+ returncode, downloaded, output = asyncio.run(pkgmgr.install_single_async("demo"))
+
+ assert returncode == 0
+ assert downloaded == 2048
+ assert "Downloading async.whl" in output
+ assert calls[0][0][-2:] == ("install", "demo")
diff --git a/tests/runtime/io/handlers/test_control_handler.py b/tests/runtime/io/handlers/test_control_handler.py
new file mode 100644
index 00000000..b221c02c
--- /dev/null
+++ b/tests/runtime/io/handlers/test_control_handler.py
@@ -0,0 +1,452 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from langbot_plugin.entities.io.actions.enums import (
+ CommonAction,
+ LangBotToRuntimeAction,
+)
+from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler
+
+from tests.helpers.protocol import ProtocolConnection, ProtocolSession
+
+
+class Dumpable:
+ def __init__(self, payload):
+ self.payload = payload
+
+ def model_dump(self, **kwargs):
+ return self.payload
+
+
+class FakePlugin:
+ def __init__(self, author="tester", name="demo"):
+ self.manifest = SimpleNamespace(
+ metadata=SimpleNamespace(author=author, name=name)
+ )
+
+ def model_dump(self, **kwargs):
+ return {"manifest": {"author": self.manifest.metadata.author, "name": self.manifest.metadata.name}}
+
+
+class FakePluginManager:
+ def __init__(self):
+ self.plugins = [FakePlugin()]
+ self.calls = []
+
+ async def get_plugin_readme(self, author, plugin_name, language):
+ self.calls.append(("get_plugin_readme", author, plugin_name, language))
+ return b"# readme"
+
+ async def get_plugin_assets_file(self, author, plugin_name, file_key):
+ self.calls.append(("get_plugin_assets_file", author, plugin_name, file_key))
+ return b"asset", "text/plain"
+
+ async def handle_page_api(
+ self,
+ plugin_author,
+ plugin_name,
+ page_id,
+ endpoint,
+ method,
+ body,
+ ):
+ self.calls.append(
+ (
+ "handle_page_api",
+ plugin_author,
+ plugin_name,
+ page_id,
+ endpoint,
+ method,
+ body,
+ )
+ )
+ return {"data": {"ok": True}, "error": None}
+
+ async def install_plugin(self, install_source, install_info):
+ self.calls.append(("install_plugin", install_source.value, install_info))
+ yield {"current_action": "downloaded"}
+ yield {"current_action": "mounted"}
+
+ async def restart_plugin(self, plugin_author, plugin_name):
+ self.calls.append(("restart_plugin", plugin_author, plugin_name))
+ yield {"current_action": "stopped"}
+
+ async def delete_plugin(self, plugin_author, plugin_name):
+ self.calls.append(("delete_plugin", plugin_author, plugin_name))
+ yield {"current_action": "deleted"}
+
+ async def list_tools(self, include_plugins=None):
+ self.calls.append(("list_tools", include_plugins))
+ return [Dumpable({"name": "weather"})]
+
+ async def call_tool(
+ self,
+ tool_name,
+ tool_parameters,
+ session,
+ query_id,
+ include_plugins=None,
+ ):
+ self.calls.append(
+ (
+ "call_tool",
+ tool_name,
+ tool_parameters,
+ session,
+ query_id,
+ include_plugins,
+ )
+ )
+ return {"text": "sunny"}
+
+ async def list_commands(self, include_plugins=None):
+ self.calls.append(("list_commands", include_plugins))
+ return [Dumpable({"name": "start"})]
+
+ async def list_knowledge_engines(self):
+ self.calls.append(("list_knowledge_engines",))
+ return [{"name": "rag"}]
+
+ async def rag_ingest_document(self, plugin_author, plugin_name, context_data):
+ self.calls.append(
+ ("rag_ingest_document", plugin_author, plugin_name, context_data)
+ )
+ return {"document_id": "doc"}
+
+ async def list_parsers(self):
+ self.calls.append(("list_parsers",))
+ return [{"name": "parser"}]
+
+ async def parse_document(self, plugin_author, plugin_name, context_data, file_bytes):
+ self.calls.append(
+ ("parse_document", plugin_author, plugin_name, context_data, file_bytes)
+ )
+ return {"text": "parsed"}
+
+
+def _handler():
+ manager = FakePluginManager()
+ context = SimpleNamespace(plugin_mgr=manager, ws_debug_port=5401)
+ handler = ControlConnectionHandler(ProtocolConnection(), context)
+ return handler, manager
+
+
+async def test_control_handler_ping_protocol_response():
+ handler, _manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(CommonAction.PING.value, seq_id=10)
+
+ assert response["seq_id"] == 10
+ assert response["code"] == 0
+ assert response["data"] == {"message": "pong"}
+
+
+async def test_control_handler_lists_plugins_over_protocol():
+ handler, _manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(LangBotToRuntimeAction.LIST_PLUGINS.value)
+
+ assert response["code"] == 0
+ assert response["data"] == {
+ "plugins": [{"manifest": {"author": "tester", "name": "demo"}}]
+ }
+
+
+async def test_control_handler_get_plugin_info_returns_match_or_none():
+ handler, _manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ found = await session.request(
+ LangBotToRuntimeAction.GET_PLUGIN_INFO.value,
+ {"author": "tester", "plugin_name": "demo"},
+ seq_id=1,
+ )
+ missing = await session.request(
+ LangBotToRuntimeAction.GET_PLUGIN_INFO.value,
+ {"author": "tester", "plugin_name": "missing"},
+ seq_id=2,
+ )
+
+ assert found["data"]["plugin"] == {"manifest": {"author": "tester", "name": "demo"}}
+ assert missing["data"]["plugin"] is None
+
+
+async def test_control_handler_get_plugin_readme_sends_file_key(monkeypatch):
+ handler, manager = _handler()
+
+ async def fake_send_file(file_bytes, extension):
+ assert file_bytes == b"# readme"
+ assert extension == "md"
+ return "readme-key"
+
+ monkeypatch.setattr(handler, "send_file", fake_send_file)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.GET_PLUGIN_README.value,
+ {
+ "plugin_author": "tester",
+ "plugin_name": "demo",
+ "language": "zh",
+ },
+ )
+
+ assert manager.calls == [("get_plugin_readme", "tester", "demo", "zh")]
+ assert response["data"] == {"readme_file_key": "readme-key"}
+
+
+async def test_control_handler_get_plugin_assets_file_sends_file_key(monkeypatch):
+ handler, manager = _handler()
+
+ async def fake_send_file(file_bytes, extension):
+ assert file_bytes == b"asset"
+ assert extension == ""
+ return "asset-key"
+
+ monkeypatch.setattr(handler, "send_file", fake_send_file)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.GET_PLUGIN_ASSETS_FILE.value,
+ {
+ "plugin_author": "tester",
+ "plugin_name": "demo",
+ "file_path": "icon.svg",
+ },
+ )
+
+ assert manager.calls == [
+ ("get_plugin_assets_file", "tester", "demo", "icon.svg")
+ ]
+ assert response["data"] == {
+ "file_file_key": "asset-key",
+ "mime_type": "text/plain",
+ }
+
+
+async def test_control_handler_page_api_validates_required_fields():
+ handler, _manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.PAGE_API.value,
+ {"plugin_author": "tester", "plugin_name": "demo"},
+ )
+
+ assert response["code"] == 0
+ assert response["data"] == {
+ "data": None,
+ "error": "Missing required field: page_id",
+ }
+
+
+async def test_control_handler_page_api_delegates_to_plugin_manager():
+ handler, manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.PAGE_API.value,
+ {
+ "plugin_author": "tester",
+ "plugin_name": "demo",
+ "page_id": "settings",
+ "endpoint": "/save",
+ "method": "POST",
+ "body": {"enabled": True},
+ },
+ )
+
+ assert manager.calls == [
+ (
+ "handle_page_api",
+ "tester",
+ "demo",
+ "settings",
+ "/save",
+ "POST",
+ {"enabled": True},
+ )
+ ]
+ assert response["data"] == {"data": {"ok": True}, "error": None}
+
+
+async def test_control_handler_install_plugin_streams_progress_and_reads_local_package(
+ monkeypatch,
+):
+ handler, manager = _handler()
+ file_ops = []
+
+ async def fake_read_local_file(file_key):
+ file_ops.append(("read", file_key))
+ return b"package"
+
+ async def fake_delete_local_file(file_key):
+ file_ops.append(("delete", file_key))
+
+ monkeypatch.setattr(handler, "read_local_file", fake_read_local_file)
+ monkeypatch.setattr(handler, "delete_local_file", fake_delete_local_file)
+
+ async with ProtocolSession(handler) as session:
+ responses = await session.request_messages(
+ LangBotToRuntimeAction.INSTALL_PLUGIN.value,
+ {
+ "install_source": "local",
+ "install_info": {"plugin_file_key": "pkg-key"},
+ },
+ count=4,
+ )
+
+ assert file_ops == [("read", "pkg-key"), ("delete", "pkg-key")]
+ assert manager.calls == [
+ (
+ "install_plugin",
+ "local",
+ {"plugin_file_key": "pkg-key", "plugin_file": b"package"},
+ )
+ ]
+ assert [response["chunk_status"] for response in responses] == [
+ "continue",
+ "continue",
+ "continue",
+ "end",
+ ]
+ assert [response["data"] for response in responses] == [
+ {"current_action": "downloaded"},
+ {"current_action": "mounted"},
+ {"current_action": "plugin installed"},
+ {},
+ ]
+
+
+async def test_control_handler_parse_document_reads_transferred_file(monkeypatch):
+ handler, manager = _handler()
+ file_ops = []
+
+ async def fake_read_local_file(file_key):
+ file_ops.append(("read", file_key))
+ return b"document"
+
+ async def fake_delete_local_file(file_key):
+ file_ops.append(("delete", file_key))
+
+ monkeypatch.setattr(handler, "read_local_file", fake_read_local_file)
+ monkeypatch.setattr(handler, "delete_local_file", fake_delete_local_file)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.PARSE_DOCUMENT.value,
+ {
+ "plugin_author": "tester",
+ "plugin_name": "demo",
+ "context": {"file_key": "file-key", "mime_type": "text/plain"},
+ },
+ )
+
+ assert file_ops == [("read", "file-key"), ("delete", "file-key")]
+ assert manager.calls == [
+ (
+ "parse_document",
+ "tester",
+ "demo",
+ {"mime_type": "text/plain"},
+ b"document",
+ )
+ ]
+ assert response["data"] == {"text": "parsed"}
+
+
+async def test_control_handler_lists_tools_and_commands_with_include_filter():
+ handler, manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ tools = await session.request(
+ LangBotToRuntimeAction.LIST_TOOLS.value,
+ {"include_plugins": ["tester/demo"]},
+ seq_id=1,
+ )
+ commands = await session.request(
+ LangBotToRuntimeAction.LIST_COMMANDS.value,
+ {"include_plugins": ["tester/demo"]},
+ seq_id=2,
+ )
+
+ assert tools["data"] == {"tools": [{"name": "weather"}]}
+ assert commands["data"] == {"commands": [{"name": "start"}]}
+ assert manager.calls == [
+ ("list_tools", ["tester/demo"]),
+ ("list_commands", ["tester/demo"]),
+ ]
+
+
+async def test_control_handler_call_tool_delegates_session_and_query_context():
+ handler, manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.CALL_TOOL.value,
+ {
+ "tool_name": "weather",
+ "tool_parameters": {"city": "Shanghai"},
+ "session": {"id": "s"},
+ "query_id": 7,
+ "include_plugins": ["tester/demo"],
+ },
+ )
+
+ assert response["data"] == {"tool_response": {"text": "sunny"}}
+ assert manager.calls == [
+ (
+ "call_tool",
+ "weather",
+ {"city": "Shanghai"},
+ {"id": "s"},
+ 7,
+ ["tester/demo"],
+ )
+ ]
+
+
+async def test_control_handler_rag_and_parser_discovery_actions():
+ handler, manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ engines = await session.request(
+ LangBotToRuntimeAction.LIST_KNOWLEDGE_ENGINES.value,
+ seq_id=1,
+ )
+ parsers = await session.request(
+ LangBotToRuntimeAction.LIST_PARSERS.value,
+ seq_id=2,
+ )
+
+ assert engines["data"] == {"engines": [{"name": "rag"}]}
+ assert parsers["data"] == {"parsers": [{"name": "parser"}]}
+ assert manager.calls == [("list_knowledge_engines",), ("list_parsers",)]
+
+
+async def test_control_handler_rag_ingest_document_delegates_context():
+ handler, manager = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ LangBotToRuntimeAction.RAG_INGEST_DOCUMENT.value,
+ {
+ "plugin_author": "tester",
+ "plugin_name": "demo",
+ "context": {"document_id": "doc"},
+ },
+ )
+
+ assert response["data"] == {"document_id": "doc"}
+ assert manager.calls == [
+ (
+ "rag_ingest_document",
+ "tester",
+ "demo",
+ {"document_id": "doc"},
+ )
+ ]
diff --git a/tests/runtime/io/handlers/test_import_contracts.py b/tests/runtime/io/handlers/test_import_contracts.py
new file mode 100644
index 00000000..15a0a400
--- /dev/null
+++ b/tests/runtime/io/handlers/test_import_contracts.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+import subprocess
+import sys
+
+
+def test_plugin_connection_handler_should_be_directly_importable():
+ result = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ (
+ "from langbot_plugin.runtime.io.handlers.plugin "
+ "import PluginConnectionHandler\n"
+ "print(PluginConnectionHandler.__name__)\n"
+ ),
+ ],
+ check=False,
+ capture_output=True,
+ text=True,
+ )
+
+ assert result.returncode == 0, result.stderr
+ assert result.stdout.strip() == "PluginConnectionHandler"
diff --git a/tests/runtime/io/handlers/test_plugin_handler.py b/tests/runtime/io/handlers/test_plugin_handler.py
new file mode 100644
index 00000000..17b68075
--- /dev/null
+++ b/tests/runtime/io/handlers/test_plugin_handler.py
@@ -0,0 +1,317 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from langbot_plugin.entities.io.actions.enums import (
+ PluginToRuntimeAction,
+ RuntimeToLangBotAction,
+)
+import langbot_plugin.runtime.plugin.container # noqa: F401
+from langbot_plugin.runtime.io.handlers import plugin as plugin_handler_module
+from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
+
+from tests.helpers.protocol import ProtocolConnection, ProtocolSession
+
+
+class FakeManifest:
+ def __init__(self, author="tester", name="demo"):
+ self.metadata = SimpleNamespace(author=author, name=name)
+
+ def model_dump(self, **kwargs):
+ return {"metadata": {"author": self.metadata.author, "name": self.metadata.name}}
+
+
+class FakePluginContainer:
+ def __init__(self, runtime_handler=None):
+ self._runtime_plugin_handler = runtime_handler
+ self.manifest = FakeManifest()
+
+ def model_dump(self, **kwargs):
+ return {"manifest": self.manifest.model_dump()}
+
+
+class FakeControlHandler:
+ def __init__(self):
+ self.calls = []
+ self.results = {}
+
+ async def call_action(self, action, data, timeout=15.0):
+ self.calls.append((action, data, timeout))
+ return self.results.get(action, {"ok": True})
+
+
+class FakePluginManager:
+ def __init__(self):
+ self.plugins = []
+ self.calls = []
+ self.tools = []
+ self.commands = []
+
+ async def register_plugin(self, handler, plugin_container, debug_plugin):
+ self.calls.append(("register_plugin", handler, plugin_container, debug_plugin))
+
+ async def remove_plugin_container(self, plugin_container):
+ self.calls.append(("remove_plugin_container", plugin_container))
+
+ async def list_tools(self):
+ self.calls.append(("list_tools",))
+ return self.tools
+
+ async def call_tool(self, tool_name, tool_parameters, session, query_id):
+ self.calls.append(
+ ("call_tool", tool_name, tool_parameters, session, query_id)
+ )
+ return {"text": "tool response"}
+
+ async def list_commands(self):
+ self.calls.append(("list_commands",))
+ return self.commands
+
+
+class FakeTool:
+ def __init__(self, name):
+ self.metadata = SimpleNamespace(name=name)
+
+ def to_plain_dict(self):
+ return {"name": self.metadata.name}
+
+
+class Dumpable:
+ def __init__(self, payload):
+ self.payload = payload
+
+ def model_dump(self, **kwargs):
+ return self.payload
+
+
+def _handler(debug_plugin=False):
+ control_handler = FakeControlHandler()
+ manager = FakePluginManager()
+ context = SimpleNamespace(control_handler=control_handler, plugin_mgr=manager)
+ handler = PluginConnectionHandler(
+ ProtocolConnection(),
+ context,
+ debug_plugin=debug_plugin,
+ )
+ return handler, manager, control_handler
+
+
+async def test_plugin_handler_registers_plugin_when_debug_key_matches(monkeypatch):
+ handler, manager, _control = _handler(debug_plugin=True)
+ monkeypatch.setattr(plugin_handler_module.runtime_settings, "plugin_debug_key", "key")
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.REGISTER_PLUGIN.value,
+ {"plugin_container": {"id": "plugin"}, "plugin_debug_key": "key"},
+ )
+
+ assert response["code"] == 0
+ assert manager.calls == [
+ ("register_plugin", handler, {"id": "plugin"}, True)
+ ]
+
+
+async def test_plugin_handler_rejects_plugin_with_invalid_debug_key(monkeypatch):
+ handler, manager, _control = _handler(debug_plugin=True)
+ monkeypatch.setattr(plugin_handler_module.runtime_settings, "plugin_debug_key", "key")
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.REGISTER_PLUGIN.value,
+ {"plugin_container": {"id": "plugin"}, "plugin_debug_key": "wrong"},
+ )
+
+ assert response["code"] == 1
+ assert response["message"] == "Plugin debug key verification failed"
+ assert manager.calls == []
+
+
+async def test_plugin_handler_prod_registration_disables_debug_mode(monkeypatch):
+ handler, manager, _control = _handler(debug_plugin=True)
+ monkeypatch.setattr(plugin_handler_module.runtime_settings, "plugin_debug_key", "")
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.REGISTER_PLUGIN.value,
+ {"plugin_container": {"id": "plugin"}, "prod_mode": True},
+ )
+
+ assert response["code"] == 0
+ assert handler.debug_plugin is False
+ assert manager.calls == [
+ ("register_plugin", handler, {"id": "plugin"}, False)
+ ]
+
+
+async def test_plugin_handler_forwards_invoke_llm_with_validated_timeout():
+ handler, _manager, control = _handler()
+ control.results[PluginToRuntimeAction.INVOKE_LLM] = {"message": "ok"}
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.INVOKE_LLM.value,
+ {"messages": [], "timeout": -1},
+ )
+
+ assert response["data"] == {"message": "ok"}
+ assert control.calls == [
+ (PluginToRuntimeAction.INVOKE_LLM, {"messages": []}, 120.0)
+ ]
+
+
+async def test_plugin_handler_adds_plugin_owner_for_binary_storage():
+ handler, manager, control = _handler()
+ manager.plugins = [FakePluginContainer(runtime_handler=handler)]
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.SET_PLUGIN_STORAGE.value,
+ {"key": "cache", "value_base64": "dmFsdWU="},
+ )
+
+ assert response["code"] == 0
+ assert control.calls == [
+ (
+ RuntimeToLangBotAction.SET_BINARY_STORAGE,
+ {
+ "key": "cache",
+ "value_base64": "dmFsdWU=",
+ "owner_type": "plugin",
+ "owner": "tester/demo",
+ },
+ 15.0,
+ )
+ ]
+
+
+async def test_plugin_handler_workspace_storage_uses_default_workspace_owner():
+ handler, _manager, control = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.GET_WORKSPACE_STORAGE.value,
+ {"key": "shared"},
+ )
+
+ assert response["code"] == 0
+ assert control.calls == [
+ (
+ RuntimeToLangBotAction.GET_BINARY_STORAGE,
+ {"key": "shared", "owner_type": "workspace", "owner": "default"},
+ 15.0,
+ )
+ ]
+
+
+async def test_plugin_handler_forwards_config_file_requests_to_langbot():
+ handler, _manager, control = _handler()
+ control.results[RuntimeToLangBotAction.GET_CONFIG_FILE] = {"file_base64": "Y29uZmln"}
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.GET_CONFIG_FILE.value,
+ {"file_key": "settings.yaml"},
+ )
+
+ assert response["data"] == {"file_base64": "Y29uZmln"}
+ assert control.calls == [
+ (
+ RuntimeToLangBotAction.GET_CONFIG_FILE,
+ {"file_key": "settings.yaml"},
+ 15.0,
+ )
+ ]
+
+
+async def test_plugin_handler_get_knowledge_file_stream_repackages_file(monkeypatch):
+ handler, _manager, control = _handler()
+ file_ops = []
+ control.results[PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM] = {
+ "file_key": "host-file"
+ }
+
+ async def fake_read_local_file(file_key):
+ file_ops.append(("read", file_key))
+ return b"file-bytes"
+
+ async def fake_delete_local_file(file_key):
+ file_ops.append(("delete", file_key))
+
+ async def fake_send_file(file_bytes, extension):
+ file_ops.append(("send", file_bytes, extension))
+ return "plugin-file"
+
+ monkeypatch.setattr(handler, "read_local_file", fake_read_local_file)
+ monkeypatch.setattr(handler, "delete_local_file", fake_delete_local_file)
+ monkeypatch.setattr(handler, "send_file", fake_send_file)
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value,
+ {"storage_path": "kb/doc"},
+ )
+
+ assert file_ops == [
+ ("read", "host-file"),
+ ("delete", "host-file"),
+ ("send", b"file-bytes", ""),
+ ]
+ assert response["data"] == {"file_key": "plugin-file"}
+
+
+async def test_plugin_handler_lists_tools_and_reports_missing_tool_detail():
+ handler, manager, _control = _handler()
+ manager.tools = [FakeTool("weather")]
+
+ async with ProtocolSession(handler) as session:
+ listed = await session.request(PluginToRuntimeAction.LIST_TOOLS.value, seq_id=1)
+ missing = await session.request(
+ PluginToRuntimeAction.GET_TOOL_DETAIL.value,
+ {"tool_name": "missing"},
+ seq_id=2,
+ )
+
+ assert listed["data"] == {"tools": [{"name": "weather"}]}
+ assert missing["code"] == 1
+ assert missing["message"] == "Tool not found: missing"
+
+
+async def test_plugin_handler_calls_registered_runtime_tool():
+ handler, manager, _control = _handler()
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.CALL_TOOL.value,
+ {
+ "tool_name": "weather",
+ "tool_parameters": {"city": "Shanghai"},
+ "session": {"id": "s"},
+ "query_id": 7,
+ },
+ )
+
+ assert response["data"] == {"tool_response": {"text": "tool response"}}
+ assert manager.calls == [
+ (
+ "call_tool",
+ "weather",
+ {"city": "Shanghai"},
+ {"id": "s"},
+ 7,
+ )
+ ]
+
+
+async def test_plugin_handler_lists_plugin_manifests():
+ handler, manager, _control = _handler()
+ manager.plugins = [FakePluginContainer()]
+
+ async with ProtocolSession(handler) as session:
+ response = await session.request(
+ PluginToRuntimeAction.LIST_PLUGINS_MANIFEST.value
+ )
+
+ assert response["data"] == {
+ "plugins": [{"metadata": {"author": "tester", "name": "demo"}}]
+ }
diff --git a/tests/runtime/io/test_connections.py b/tests/runtime/io/test_connections.py
new file mode 100644
index 00000000..1552f910
--- /dev/null
+++ b/tests/runtime/io/test_connections.py
@@ -0,0 +1,148 @@
+from __future__ import annotations
+
+import json
+
+from langbot_plugin.runtime.io.connections.stdio import StdioConnection
+from langbot_plugin.runtime.io.connections.ws import WebSocketConnection
+
+
+class FakeStreamReader:
+ def __init__(self, lines: list[bytes]):
+ self.lines = lines
+
+ async def readline(self):
+ return self.lines.pop(0)
+
+
+class FakeStreamWriter:
+ def __init__(self):
+ self.writes: list[bytes] = []
+ self.closed = False
+
+ def write(self, data: bytes):
+ self.writes.append(data)
+
+ async def drain(self):
+ pass
+
+ def close(self):
+ self.closed = True
+
+
+class AsyncChunkIterator:
+ def __init__(self, chunks: list[str]):
+ self.chunks = chunks
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self):
+ if not self.chunks:
+ raise StopAsyncIteration
+ return self.chunks.pop(0)
+
+
+class FakeWebSocket:
+ def __init__(self, receive_batches: list[list[str]] | None = None):
+ self.sent: list[tuple[str, bool]] = []
+ self.receive_batches = receive_batches or []
+ self.closed = False
+
+ async def send(self, data: str, text: bool = False):
+ self.sent.append((data, text))
+
+ def recv_streaming(self, decode: bool = False):
+ return AsyncChunkIterator(self.receive_batches.pop(0))
+
+ async def close(self):
+ self.closed = True
+
+
+async def test_stdio_connection_sends_small_message_with_newline():
+ writer = FakeStreamWriter()
+ connection = StdioConnection(FakeStreamReader([]), writer)
+
+ await connection.send('{"ok": true}')
+
+ assert writer.writes == [b'{"ok": true}\n']
+
+
+async def test_stdio_connection_sends_large_message_as_json_chunks():
+ writer = FakeStreamWriter()
+ connection = StdioConnection(FakeStreamReader([]), writer, chunk_size=4)
+
+ await connection.send("abcdefghi")
+
+ payloads = [json.loads(line.decode()) for line in writer.writes]
+ assert payloads[0] == {"type": "chunk_start", "total_size": 9}
+ assert payloads[1:4] == [
+ {"type": "chunk_data", "data": "abcd", "offset": 0},
+ {"type": "chunk_data", "data": "efgh", "offset": 4},
+ {"type": "chunk_data", "data": "i", "offset": 8},
+ ]
+ assert payloads[4] == {"type": "chunk_end"}
+
+
+async def test_stdio_connection_receives_json_message_after_blank_line():
+ reader = FakeStreamReader([b"\n", b'{"type": "event", "id": 1}\n'])
+ connection = StdioConnection(reader, FakeStreamWriter())
+
+ assert await connection.receive() == '{"type": "event", "id": 1}'
+
+
+async def test_stdio_connection_reassembles_chunked_message():
+ chunk_lines = [
+ {"type": "chunk_start", "total_size": 11},
+ {"type": "chunk_data", "data": "hello ", "offset": 0},
+ {"type": "chunk_data", "data": "world", "offset": 6},
+ {"type": "chunk_end"},
+ ]
+ reader = FakeStreamReader(
+ [json.dumps(chunk).encode() + b"\n" for chunk in chunk_lines]
+ )
+ connection = StdioConnection(reader, FakeStreamWriter())
+
+ assert await connection.receive() == "hello world"
+
+
+async def test_stdio_connection_close_closes_writer():
+ writer = FakeStreamWriter()
+ connection = StdioConnection(FakeStreamReader([]), writer)
+
+ await connection.close()
+
+ assert writer.closed is True
+
+
+async def test_websocket_connection_sends_small_message_directly():
+ websocket = FakeWebSocket()
+ connection = WebSocketConnection(websocket)
+
+ await connection.send('{"ok": true}')
+
+ assert websocket.sent == [('{"ok": true}', True)]
+
+
+async def test_websocket_connection_sends_large_message_in_chunks():
+ websocket = FakeWebSocket()
+ connection = WebSocketConnection(websocket, chunk_size=4)
+
+ await connection.send("abcdefghi")
+
+ assert websocket.sent == [("abcd", True), ("efgh", True), ("i", True)]
+
+
+async def test_websocket_connection_receives_streamed_json_message():
+ websocket = FakeWebSocket(receive_batches=[['{"ok": ', "true}"]])
+ connection = WebSocketConnection(websocket)
+
+ assert await connection.receive() == '{"ok": true}'
+
+
+async def test_websocket_connection_close_closes_socket():
+ websocket = FakeWebSocket()
+ connection = WebSocketConnection(websocket)
+
+ await connection.close()
+
+ assert websocket.closed is True
diff --git a/tests/runtime/io/test_controllers.py b/tests/runtime/io/test_controllers.py
new file mode 100644
index 00000000..4886fd3e
--- /dev/null
+++ b/tests/runtime/io/test_controllers.py
@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+import pytest
+
+from langbot_plugin.runtime.io.connections.stdio import StdioConnection
+from langbot_plugin.runtime.io.connections.ws import WebSocketConnection
+from langbot_plugin.runtime.io.controllers.stdio import client as stdio_client
+from langbot_plugin.runtime.io.controllers.stdio import server as stdio_server
+from langbot_plugin.runtime.io.controllers.ws import client as ws_client
+from langbot_plugin.runtime.io.controllers.ws import server as ws_server
+
+
+class FakeProcess:
+ def __init__(self, stdin=object(), stdout=object()):
+ self.stdin = stdin
+ self.stdout = stdout
+
+
+class FakeWebSocket:
+ remote_address = ("127.0.0.1", 12345)
+
+
+class FakeWebSocketConnectContext:
+ def __init__(self, websocket):
+ self.websocket = websocket
+
+ async def __aenter__(self):
+ return self.websocket
+
+ async def __aexit__(self, exc_type, exc, traceback):
+ return False
+
+
+class FakeServer:
+ def __init__(self):
+ self.waited = False
+
+ async def wait_closed(self):
+ self.waited = True
+
+
+async def test_stdio_client_controller_creates_process_connection(monkeypatch):
+ process = FakeProcess()
+ captured = {}
+
+ async def fake_create_subprocess_exec(*args, **kwargs):
+ captured["args"] = args
+ captured["kwargs"] = kwargs
+ return process
+
+ async def callback(connection):
+ captured["connection"] = connection
+
+ monkeypatch.setattr(
+ stdio_client.asyncio,
+ "create_subprocess_exec",
+ fake_create_subprocess_exec,
+ )
+ controller = stdio_client.StdioClientController(
+ command="python",
+ args=["plugin.py"],
+ env={"TOKEN": "secret"},
+ working_dir="/tmp/plugin",
+ )
+
+ await controller.run(callback)
+
+ assert captured["args"][:2] == ("python", "plugin.py")
+ assert captured["kwargs"]["env"] == {"TOKEN": "secret"}
+ assert captured["kwargs"]["cwd"] == "/tmp/plugin"
+ assert isinstance(captured["connection"], StdioConnection)
+ assert captured["connection"].process is process
+
+
+async def test_stdio_client_controller_rejects_missing_pipes(monkeypatch):
+ async def fake_create_subprocess_exec(*args, **kwargs):
+ return FakeProcess(stdin=None, stdout=object())
+
+ monkeypatch.setattr(
+ stdio_client.asyncio,
+ "create_subprocess_exec",
+ fake_create_subprocess_exec,
+ )
+ controller = stdio_client.StdioClientController("python", [], {}, ".")
+
+ with pytest.raises(RuntimeError, match="Failed to create subprocess pipes"):
+ await controller.run(lambda connection: None)
+
+
+async def test_stdio_server_controller_wraps_standard_streams(monkeypatch):
+ captured = {}
+
+ async def fake_connect_stdin_stdout():
+ return object(), object()
+
+ async def callback(connection):
+ captured["connection"] = connection
+
+ monkeypatch.setattr(
+ stdio_server,
+ "connect_stdin_stdout",
+ fake_connect_stdin_stdout,
+ )
+
+ await stdio_server.StdioServerController().run(callback)
+
+ assert isinstance(captured["connection"], StdioConnection)
+
+
+async def test_websocket_client_controller_invokes_connection_callback(monkeypatch):
+ captured = {}
+ websocket = FakeWebSocket()
+
+ def fake_connect(url, open_timeout):
+ captured["url"] = url
+ captured["open_timeout"] = open_timeout
+ return FakeWebSocketConnectContext(websocket)
+
+ async def callback(connection):
+ captured["connection"] = connection
+
+ async def on_failed(controller, exc):
+ captured["failure"] = (controller, exc)
+
+ monkeypatch.setattr(ws_client.websockets, "connect", fake_connect)
+ controller = ws_client.WebSocketClientController("ws://localhost:9000", on_failed)
+
+ await controller.run(callback)
+
+ assert captured["url"] == "ws://localhost:9000"
+ assert captured["open_timeout"] == 10
+ assert isinstance(captured["connection"], WebSocketConnection)
+ assert "failure" not in captured
+
+
+async def test_websocket_client_controller_reports_connection_failure(monkeypatch):
+ captured = {}
+ error = OSError("network down")
+
+ def fake_connect(url, open_timeout):
+ raise error
+
+ async def callback(connection):
+ captured["connection"] = connection
+
+ async def on_failed(controller, exc):
+ captured["failure"] = (controller, exc)
+
+ monkeypatch.setattr(ws_client.websockets, "connect", fake_connect)
+ controller = ws_client.WebSocketClientController("ws://localhost:9000", on_failed)
+
+ await controller.run(callback)
+
+ assert captured["failure"] == (controller, error)
+ assert "connection" not in captured
+
+
+async def test_websocket_server_controller_run_waits_for_server(monkeypatch):
+ fake_server = FakeServer()
+ captured = {}
+
+ async def fake_serve(handler, host, port):
+ captured["handler"] = handler
+ captured["host"] = host
+ captured["port"] = port
+ return fake_server
+
+ async def callback(connection):
+ captured["connection"] = connection
+
+ monkeypatch.setattr(ws_server.websockets, "serve", fake_serve)
+ controller = ws_server.WebSocketServerController(port=9000)
+
+ await controller.run(callback)
+
+ assert captured["handler"] == controller.handle_connection
+ assert captured["host"] == "0.0.0.0"
+ assert captured["port"] == 9000
+ assert fake_server.waited is True
+
+
+async def test_websocket_server_controller_wraps_new_connections():
+ captured = {}
+ controller = ws_server.WebSocketServerController(port=9000)
+
+ async def callback(connection):
+ captured["connection"] = connection
+
+ controller._new_connection_callback = callback
+
+ await controller.handle_connection(FakeWebSocket())
+
+ assert isinstance(captured["connection"], WebSocketConnection)
diff --git a/tests/runtime/io/test_handler.py b/tests/runtime/io/test_handler.py
new file mode 100644
index 00000000..61ac55af
--- /dev/null
+++ b/tests/runtime/io/test_handler.py
@@ -0,0 +1,228 @@
+from __future__ import annotations
+
+import asyncio
+import json
+
+import pytest
+
+from langbot_plugin.entities.io.actions.enums import ActionType, CommonAction
+from langbot_plugin.entities.io.errors import (
+ ActionCallError,
+ ActionCallTimeoutError,
+ ConnectionClosedError,
+)
+from langbot_plugin.entities.io.resp import ActionResponse, ChunkStatus
+from langbot_plugin.runtime.io.connection import Connection
+from langbot_plugin.runtime.io.handler import Handler
+
+
+class SampleAction(ActionType):
+ ECHO = "echo"
+ STREAM = "stream"
+
+
+class QueueConnection(Connection):
+ def __init__(self):
+ self.incoming: asyncio.Queue[str | BaseException] = asyncio.Queue()
+ self.sent: list[str] = []
+ self.sent_event = asyncio.Event()
+ self.closed = False
+
+ async def send(self, message: str) -> None:
+ self.sent.append(message)
+ self.sent_event.set()
+
+ async def receive(self) -> str:
+ message = await self.incoming.get()
+ if isinstance(message, BaseException):
+ raise message
+ return message
+
+ async def close(self) -> None:
+ self.closed = True
+
+
+async def _wait_for_sent(conn: QueueConnection, count: int = 1) -> list[dict]:
+ for _ in range(50):
+ if len(conn.sent) >= count:
+ return [json.loads(message) for message in conn.sent]
+ await asyncio.sleep(0.01)
+ raise AssertionError(f"timed out waiting for {count} sent messages")
+
+
+@pytest.mark.asyncio
+async def test_call_action_sends_request_and_returns_response_data():
+ conn = QueueConnection()
+ handler = Handler(conn)
+
+ task = asyncio.create_task(
+ handler.call_action(SampleAction.ECHO, {"message": "hello"}, timeout=1)
+ )
+ [request] = await _wait_for_sent(conn)
+ assert request["action"] == "echo"
+ assert request["data"] == {"message": "hello"}
+
+ handler.resp_waiters[request["seq_id"]].set_result(
+ ActionResponse(seq_id=request["seq_id"], code=0, message="ok", data={"ok": True})
+ )
+
+ assert await task == {"ok": True}
+ assert request["seq_id"] not in handler.resp_waiters
+
+
+@pytest.mark.asyncio
+async def test_call_action_timeout_cleans_waiter():
+ conn = QueueConnection()
+ handler = Handler(conn)
+
+ with pytest.raises(ActionCallTimeoutError, match="Action echo call timed out"):
+ await handler.call_action(SampleAction.ECHO, {}, timeout=0.01)
+
+ assert handler.resp_waiters == {}
+
+
+@pytest.mark.asyncio
+async def test_call_action_error_response_should_preserve_peer_message():
+ conn = QueueConnection()
+ handler = Handler(conn)
+ task = asyncio.create_task(handler.call_action(SampleAction.ECHO, {}, timeout=1))
+ [request] = await _wait_for_sent(conn)
+
+ handler.resp_waiters[request["seq_id"]].set_result(
+ ActionResponse(seq_id=request["seq_id"], code=1, message="peer failed", data={})
+ )
+
+ with pytest.raises(ActionCallError, match="^peer failed$"):
+ await task
+
+
+@pytest.mark.asyncio
+async def test_call_action_generator_yields_chunks_until_end():
+ conn = QueueConnection()
+ handler = Handler(conn)
+ chunks: list[dict] = []
+
+ async def consume():
+ async for chunk in handler.call_action_generator(
+ SampleAction.STREAM, {}, timeout=1
+ ):
+ chunks.append(chunk)
+
+ task = asyncio.create_task(consume())
+ [request] = await _wait_for_sent(conn)
+ queue = handler.resp_queues[request["seq_id"]]
+ await queue.put(
+ ActionResponse(
+ seq_id=request["seq_id"],
+ code=0,
+ message="ok",
+ data={"part": 1},
+ chunk_status=ChunkStatus.CONTINUE,
+ )
+ )
+ await queue.put(
+ ActionResponse(
+ seq_id=request["seq_id"],
+ code=0,
+ message="ok",
+ data={},
+ chunk_status=ChunkStatus.END,
+ )
+ )
+
+ await task
+ assert chunks == [{"part": 1}]
+ assert handler.resp_queues == {}
+
+
+@pytest.mark.asyncio
+async def test_run_dispatches_registered_action_and_sends_response():
+ conn = QueueConnection()
+ handler = Handler(conn)
+
+ @handler.action(SampleAction.ECHO)
+ async def echo(data):
+ return ActionResponse.success({"echo": data["message"]})
+
+ task = asyncio.create_task(handler.run())
+ await conn.incoming.put(
+ json.dumps({"seq_id": 7, "action": "echo", "data": {"message": "hi"}})
+ )
+ [response] = await _wait_for_sent(conn)
+ await conn.incoming.put(ConnectionClosedError("closed"))
+ await task
+
+ assert response["seq_id"] == 7
+ assert response["code"] == 0
+ assert response["data"] == {"echo": "hi"}
+
+
+@pytest.mark.asyncio
+async def test_run_sends_error_response_for_unknown_action():
+ conn = QueueConnection()
+ handler = Handler(conn)
+
+ task = asyncio.create_task(handler.run())
+ await conn.incoming.put(json.dumps({"seq_id": 9, "action": "missing", "data": {}}))
+ [response] = await _wait_for_sent(conn)
+ await conn.incoming.put(ConnectionClosedError("closed"))
+ await task
+
+ assert response["seq_id"] == 9
+ assert response["code"] == 1
+ assert "Action missing not found" in response["message"]
+
+
+@pytest.mark.asyncio
+async def test_run_handles_streaming_action_response():
+ conn = QueueConnection()
+ handler = Handler(conn)
+
+ @handler.action(SampleAction.STREAM)
+ async def stream(_data):
+ yield ActionResponse.success({"part": 1})
+ yield ActionResponse.success({"part": 2})
+
+ task = asyncio.create_task(handler.run())
+ await conn.incoming.put(json.dumps({"seq_id": 3, "action": "stream", "data": {}}))
+ responses = await _wait_for_sent(conn, count=3)
+ await conn.incoming.put(ConnectionClosedError("closed"))
+ await task
+
+ assert [response["chunk_status"] for response in responses] == [
+ "continue",
+ "continue",
+ "end",
+ ]
+ assert [response["data"] for response in responses] == [
+ {"part": 1},
+ {"part": 2},
+ {},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_send_file_calls_file_chunk_action_for_each_chunk(monkeypatch):
+ conn = QueueConnection()
+ handler = Handler(conn)
+ calls: list[dict] = []
+
+ async def fake_call_action(action, data, timeout=15.0):
+ calls.append({"action": action, "data": data, "timeout": timeout})
+ return {}
+
+ monkeypatch.setattr(handler, "call_action", fake_call_action)
+ file_key = await handler.send_file(b"abc", "txt")
+
+ assert file_key.endswith(".txt")
+ assert calls[0]["action"] is CommonAction.FILE_CHUNK
+ assert calls[0]["data"]["file_length"] == 3
+ assert calls[0]["data"]["chunk_amount"] == 1
+
+
+def test_handler_file_storage_dir_is_created_for_instances(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+
+ Handler(QueueConnection())
+
+ assert (tmp_path / "data" / "temp" / "lbp").is_dir()
diff --git a/tests/runtime/plugin/test_container.py b/tests/runtime/plugin/test_container.py
new file mode 100644
index 00000000..f0edf1e8
--- /dev/null
+++ b/tests/runtime/plugin/test_container.py
@@ -0,0 +1,124 @@
+from __future__ import annotations
+
+from langbot_plugin.api.definition.components.base import NoneComponent
+from langbot_plugin.api.definition.components.manifest import ComponentManifest
+from langbot_plugin.api.definition.plugin import NonePlugin
+from langbot_plugin.runtime.plugin.container import (
+ ComponentContainer,
+ PluginContainer,
+ RuntimeContainerStatus,
+)
+
+
+def _manifest(kind: str = "Plugin", name: str = "demo") -> ComponentManifest:
+ return ComponentManifest(
+ owner="tester",
+ manifest={
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": {
+ "name": name,
+ "label": {"en_US": name.title()},
+ "author": "tester",
+ "version": "1.0.0",
+ },
+ "spec": {"components": {}},
+ },
+ rel_path="manifest.yaml",
+ )
+
+
+def test_component_container_dump_excludes_runtime_instance():
+ container = ComponentContainer(
+ manifest=_manifest(kind="Tool", name="weather"),
+ component_instance=NoneComponent(),
+ component_config={"enabled": True},
+ )
+
+ dumped = container.model_dump()
+
+ assert dumped["component_instance"] is None
+ assert dumped["component_config"] == {"enabled": True}
+ assert dumped["manifest"]["manifest"]["kind"] == "Tool"
+
+
+def test_component_container_roundtrip_uses_none_component_placeholder():
+ original = ComponentContainer(
+ manifest=_manifest(kind="Command", name="hello"),
+ component_instance=NoneComponent(),
+ component_config={"prefix": "/"},
+ )
+
+ restored = ComponentContainer.from_dict(original.model_dump())
+
+ assert isinstance(restored.component_instance, NoneComponent)
+ assert restored.component_config == {"prefix": "/"}
+ assert restored.manifest.kind == "Command"
+
+
+def test_plugin_container_dump_excludes_plugin_instance_and_serializes_status():
+ component = ComponentContainer(
+ manifest=_manifest(kind="Tool", name="weather"),
+ component_instance=NoneComponent(),
+ component_config={},
+ )
+ container = PluginContainer(
+ debug=True,
+ install_source="local",
+ install_info={"path": "."},
+ manifest=_manifest(),
+ plugin_instance=NonePlugin(),
+ enabled=True,
+ priority=10,
+ plugin_config={"token": "x"},
+ status=RuntimeContainerStatus.INITIALIZED,
+ components=[component],
+ )
+
+ dumped = container.model_dump()
+
+ assert dumped["plugin_instance"] is None
+ assert dumped["status"] == "initialized"
+ assert dumped["components"][0]["component_instance"] is None
+
+
+def test_plugin_container_roundtrip_uses_none_plugin_placeholder():
+ container = PluginContainer(
+ debug=False,
+ install_source="marketplace",
+ install_info={"id": "tester/demo"},
+ manifest=_manifest(),
+ plugin_instance=NonePlugin(),
+ enabled=False,
+ priority=0,
+ plugin_config={},
+ status=RuntimeContainerStatus.MOUNTED,
+ components=[],
+ )
+
+ restored = PluginContainer.from_dict(container.model_dump())
+
+ assert isinstance(restored.plugin_instance, NonePlugin)
+ assert restored.status is RuntimeContainerStatus.MOUNTED
+ assert restored.enabled is False
+ assert restored.components == []
+
+
+def test_plugin_container_roundtrip_should_preserve_install_metadata():
+ container = PluginContainer(
+ debug=False,
+ install_source="marketplace",
+ install_info={"id": "tester/demo"},
+ manifest=_manifest(),
+ plugin_instance=NonePlugin(),
+ enabled=True,
+ priority=0,
+ plugin_config={},
+ status=RuntimeContainerStatus.MOUNTED,
+ components=[],
+ )
+
+ restored = PluginContainer.from_dict(container.model_dump())
+
+ assert restored.install_source == "marketplace"
+ assert restored.install_info == {"id": "tester/demo"}
diff --git a/tests/runtime/plugin/test_manager.py b/tests/runtime/plugin/test_manager.py
new file mode 100644
index 00000000..3349ccb8
--- /dev/null
+++ b/tests/runtime/plugin/test_manager.py
@@ -0,0 +1,536 @@
+from __future__ import annotations
+
+import asyncio
+import io
+import zipfile
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+
+from langbot_plugin.api.definition.components.base import NoneComponent
+from langbot_plugin.api.definition.components.manifest import ComponentManifest
+from langbot_plugin.api.definition.plugin import NonePlugin
+from langbot_plugin.api.entities.builtin.command.context import CommandReturn
+from langbot_plugin.api.entities.context import EventContext
+from langbot_plugin.api.entities.events import PersonCommandSent
+from langbot_plugin.runtime.plugin.container import (
+ ComponentContainer,
+ PluginContainer,
+ RuntimeContainerStatus,
+)
+from langbot_plugin.runtime.plugin.mgr import PluginInstallSource, PluginManager
+
+
+def _manifest(
+ kind: str = "Plugin",
+ name: str = "demo",
+ author: str = "tester",
+ spec: dict[str, Any] | None = None,
+) -> ComponentManifest:
+ return ComponentManifest(
+ owner=author,
+ rel_path=f"{name}.yaml",
+ manifest={
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": {
+ "name": name,
+ "label": {"en_US": name.title()},
+ "author": author,
+ "version": "1.0.0",
+ },
+ "spec": spec or {},
+ },
+ )
+
+
+def _component(kind: str, name: str, spec: dict[str, Any] | None = None):
+ return ComponentContainer(
+ manifest=_manifest(kind=kind, name=name, spec=spec),
+ component_instance=NoneComponent(),
+ component_config={},
+ )
+
+
+def _plugin(
+ name: str = "demo",
+ author: str = "tester",
+ *,
+ components: list[ComponentContainer] | None = None,
+ status: RuntimeContainerStatus = RuntimeContainerStatus.INITIALIZED,
+ enabled: bool = True,
+ debug: bool = False,
+) -> PluginContainer:
+ return PluginContainer(
+ debug=debug,
+ install_source="local",
+ install_info={},
+ manifest=_manifest(name=name, author=author),
+ plugin_instance=NonePlugin(),
+ enabled=enabled,
+ priority=0,
+ plugin_config={},
+ status=status,
+ components=components or [],
+ )
+
+
+def _manager() -> PluginManager:
+ manager = PluginManager(SimpleNamespace(ws_debug_port=18080))
+ manager.plugins = []
+ manager.plugin_handlers = []
+ manager.plugin_run_tasks = []
+ return manager
+
+
+def _plugin_zip(author: str = "tester", name: str = "demo", version: str = "1.0.0"):
+ manifest = f"""
+apiVersion: v1
+kind: Plugin
+metadata:
+ name: {name}
+ label:
+ en_US: Demo
+ author: {author}
+ version: {version}
+spec: {{}}
+"""
+ buffer = io.BytesIO()
+ with zipfile.ZipFile(buffer, "w") as archive:
+ archive.writestr("manifest.yaml", manifest)
+ archive.writestr("main.py", "")
+ return buffer.getvalue()
+
+
+class FakeControlHandler:
+ def __init__(self):
+ self.calls: list[tuple[Any, dict[str, Any]]] = []
+
+ async def call_action(self, action, payload):
+ self.calls.append((action, payload))
+ return {
+ "enabled": True,
+ "priority": 7,
+ "plugin_config": {"api_key": "secret"},
+ "install_source": PluginInstallSource.MARKETPLACE.value,
+ "install_info": {"plugin_version": "1.0.0"},
+ }
+
+
+class FakeConnection:
+ closed = False
+
+ async def close(self):
+ self.closed = True
+
+
+class FakeHandler:
+ def __init__(self, plugin: PluginContainer):
+ self.plugin = plugin
+ self.debug_plugin = False
+ self.conn = FakeConnection()
+ self.stdio_process = None
+ self.initialized_with = None
+ self.shutdown_calls = 0
+ self.files = {
+ "icon-key": b"",
+ "readme-key": b"# Demo",
+ "asset-key": b"asset-bytes",
+ }
+
+ async def initialize_plugin(self, plugin_settings):
+ self.initialized_with = plugin_settings
+
+ async def get_plugin_container(self):
+ refreshed = self.plugin.model_dump()
+ refreshed["status"] = RuntimeContainerStatus.INITIALIZED.value
+ return refreshed
+
+ async def shutdown_plugin(self):
+ self.shutdown_calls += 1
+
+ async def emit_event(self, event_context):
+ return {"emitted": True, "event_context": event_context}
+
+ async def call_tool(self, tool_name, tool_parameters, session, query_id):
+ return {
+ "tool_response": {
+ "tool_name": tool_name,
+ "params": tool_parameters,
+ "query_id": query_id,
+ }
+ }
+
+ async def execute_command(self, command_context):
+ yield {"command_response": {"text": command_context["command"]}}
+
+ async def get_plugin_icon(self):
+ return {"plugin_icon_file_key": "icon-key", "mime_type": "image/svg+xml"}
+
+ async def get_plugin_readme(self, language="en"):
+ return {"plugin_readme_file_key": "readme-key", "language": language}
+
+ async def get_plugin_assets_file(self, file_key):
+ if file_key == "missing":
+ return {"file_file_key": "", "mime_type": ""}
+ return {"file_file_key": "asset-key", "mime_type": "text/plain"}
+
+ async def read_local_file(self, file_key):
+ return self.files[file_key]
+
+ async def delete_local_file(self, file_key):
+ del self.files[file_key]
+
+ async def call_page_api(self, page_id, endpoint, method, body):
+ return {
+ "data": {
+ "page_id": page_id,
+ "endpoint": endpoint,
+ "method": method,
+ "body": body,
+ },
+ "error": None,
+ }
+
+ async def get_rag_capabilities(self):
+ return {"capabilities": ["ingest", "retrieve"]}
+
+ async def rag_ingest_document(self, context_data):
+ return {"ingested": context_data["document_id"]}
+
+ async def rag_delete_document(self, kb_id, document_id):
+ return {"deleted": [kb_id, document_id]}
+
+ async def rag_on_kb_create(self, kb_id, config):
+ return {"created": kb_id, "config": config}
+
+ async def rag_on_kb_delete(self, kb_id):
+ return {"deleted_kb": kb_id}
+
+ async def parse_document(self, context_data, file_bytes):
+ return {"context": context_data, "text": file_bytes.decode()}
+
+
+def test_plugin_manager_instances_should_not_share_plugin_state():
+ PluginManager.plugins = []
+ first = PluginManager(SimpleNamespace())
+ second = PluginManager(SimpleNamespace())
+
+ first.plugins.append(_plugin())
+
+ assert second.plugins == []
+
+
+def test_find_plugin_and_component_lists_respect_include_filters():
+ manager = _manager()
+ tool = _component("Tool", "lookup")
+ command = _component("Command", "admin")
+ parser = _component("Parser", "pdf", {"supported_mime_types": ["application/pdf"]})
+ manager.plugins = [
+ _plugin(name="demo", components=[tool, command, parser]),
+ _plugin(name="other", components=[_component("Tool", "skip")]),
+ ]
+
+ assert manager.find_plugin("tester", "demo") is manager.plugins[0]
+ assert manager.find_plugin("tester", "missing") is None
+
+ tools = asyncio.run(manager.list_tools(include_plugins=["tester/demo"]))
+ commands = asyncio.run(manager.list_commands(include_plugins=["tester/demo"]))
+ parsers = asyncio.run(manager.list_parsers())
+
+ assert [tool.metadata.name for tool in tools] == ["lookup"]
+ assert [command.metadata.name for command in commands] == ["admin"]
+ assert parsers[0]["plugin_id"] == "tester/demo"
+ assert parsers[0]["supported_mime_types"] == ["application/pdf"]
+
+
+@pytest.mark.asyncio
+async def test_install_plugin_from_file_extracts_manifest_and_replaces_old_version(
+ tmp_path,
+ monkeypatch,
+):
+ monkeypatch.chdir(tmp_path)
+ manager = _manager()
+ old_plugin = _plugin(name="demo")
+ old_plugin._runtime_plugin_handler = FakeHandler(old_plugin)
+ manager.plugins = [old_plugin]
+ old_path = tmp_path / "data/plugins/tester__demo"
+ old_path.mkdir(parents=True)
+ (old_path / "old.py").write_text("", encoding="utf-8")
+
+ new_path, author, name, version = await manager.install_plugin_from_file(
+ _plugin_zip(version="2.0.0")
+ )
+
+ assert (tmp_path / "data/plugins/tester__demo/manifest.yaml").exists()
+ assert new_path == "data/plugins/tester__demo"
+ assert (author, name, version) == ("tester", "demo", "2.0.0")
+ assert manager.plugins == []
+
+
+@pytest.mark.asyncio
+async def test_install_plugin_from_file_rejects_same_version_duplicate(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ manager = _manager()
+ manager.plugins = [_plugin(name="demo")]
+
+ with pytest.raises(ValueError, match="already exists"):
+ await manager.install_plugin_from_file(_plugin_zip(version="1.0.0"))
+
+
+@pytest.mark.asyncio
+async def test_install_plugin_raises_when_dependency_install_fails(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ plugin_path = tmp_path / "data/plugins/tester__demo"
+ plugin_path.mkdir(parents=True)
+ (plugin_path / "requirements.txt").write_text("missing-package\n", encoding="utf-8")
+ manager = _manager()
+
+ async def fake_install_from_file(plugin_file):
+ return "data/plugins/tester__demo", "tester", "demo", "1.0.0"
+
+ async def fake_install_single_async(dep):
+ return 1, 0, "pip could not find package"
+
+ monkeypatch.setattr(manager, "install_plugin_from_file", fake_install_from_file)
+ monkeypatch.setattr(
+ "langbot_plugin.runtime.plugin.mgr.pkgmgr_helper.install_single_async",
+ fake_install_single_async,
+ )
+
+ progress = []
+ with pytest.raises(RuntimeError, match="pip could not find package"):
+ async for item in manager.install_plugin(
+ PluginInstallSource.LOCAL,
+ {"plugin_file": b"zip"},
+ ):
+ progress.append(item["current_action"])
+
+ assert progress[0] == "downloading plugin package"
+ assert "installing dependencies" in progress
+ assert "initializing plugin settings" not in progress
+ assert "launching plugin" not in progress
+
+
+@pytest.mark.asyncio
+async def test_register_plugin_initializes_settings_and_refreshes_container():
+ manager = _manager()
+ control_handler = FakeControlHandler()
+ manager.context = SimpleNamespace(control_handler=control_handler)
+ plugin = _plugin(
+ name="demo",
+ components=[_component("Tool", "lookup")],
+ status=RuntimeContainerStatus.MOUNTED,
+ )
+ handler = FakeHandler(plugin)
+
+ await manager.register_plugin(handler, plugin.model_dump())
+
+ registered = manager.plugins[0]
+ assert registered._runtime_plugin_handler is handler
+ assert registered.install_source == PluginInstallSource.MARKETPLACE.value
+ assert registered.install_info == {"plugin_version": "1.0.0"}
+ assert registered.status is RuntimeContainerStatus.INITIALIZED
+ assert [component.manifest.metadata.name for component in registered.components] == [
+ "lookup"
+ ]
+ assert handler.initialized_with["plugin_config"] == {"api_key": "secret"}
+
+
+@pytest.mark.asyncio
+async def test_call_tool_and_execute_command_delegate_to_connected_plugin():
+ manager = _manager()
+ plugin = _plugin(
+ components=[
+ _component("Tool", "lookup"),
+ _component("Command", "admin"),
+ ]
+ )
+ plugin._runtime_plugin_handler = FakeHandler(plugin)
+ manager.plugins = [plugin]
+
+ tool_response = await manager.call_tool(
+ "lookup",
+ {"city": "Paris"},
+ {"launcher_type": "person"},
+ query_id=99,
+ )
+ command_responses = [
+ response
+ async for response in manager.execute_command(
+ SimpleNamespace(
+ command="admin",
+ model_dump=lambda mode="json": {"command": "admin"},
+ )
+ )
+ ]
+
+ assert tool_response == {
+ "tool_name": "lookup",
+ "params": {"city": "Paris"},
+ "query_id": 99,
+ }
+ assert command_responses == [CommandReturn(text="admin")]
+
+
+@pytest.mark.asyncio
+async def test_shutdown_plugin_closes_connection_and_removes_container():
+ manager = _manager()
+ plugin = _plugin()
+ handler = FakeHandler(plugin)
+ plugin._runtime_plugin_handler = handler
+ manager.plugins = [plugin]
+ manager.plugin_handlers = [handler]
+
+ await manager.shutdown_plugin(plugin)
+
+ assert manager.plugins == []
+ assert manager.plugin_handlers == []
+ assert handler.shutdown_calls == 1
+ assert handler.conn.closed is True
+
+
+@pytest.mark.asyncio
+async def test_plugin_resource_and_page_methods_delegate_to_connected_handler():
+ manager = _manager()
+ plugin = _plugin()
+ handler = FakeHandler(plugin)
+ plugin._runtime_plugin_handler = handler
+ manager.plugins = [plugin]
+
+ assert await manager.get_plugin_icon("tester", "missing") == (b"", "")
+ assert await manager.get_plugin_icon("tester", "demo") == (
+ b"",
+ "image/svg+xml",
+ )
+ assert "icon-key" not in handler.files
+ assert await manager.get_plugin_readme("tester", "demo", language="zh") == b"# Demo"
+ assert "readme-key" not in handler.files
+ assert await manager.get_plugin_assets_file("tester", "demo", "missing") == (
+ b"",
+ "",
+ )
+ assert await manager.get_plugin_assets_file("tester", "demo", "asset.txt") == (
+ b"asset-bytes",
+ "text/plain",
+ )
+ assert "asset-key" not in handler.files
+
+ page_response = await manager.handle_page_api(
+ "tester",
+ "demo",
+ page_id="settings",
+ endpoint="/save",
+ method="POST",
+ body={"enabled": True},
+ )
+ assert page_response["data"]["page_id"] == "settings"
+ assert page_response["data"]["body"] == {"enabled": True}
+ assert await manager.handle_page_api(
+ "tester",
+ "missing",
+ page_id="settings",
+ endpoint="/save",
+ method="POST",
+ ) == {"data": None, "error": "Plugin not found"}
+
+
+@pytest.mark.asyncio
+async def test_knowledge_engine_methods_validate_components_and_delegate_to_handler():
+ manager = _manager()
+ rag = _component(
+ "KnowledgeEngine",
+ "kb",
+ {
+ "creation_schema": [{"name": "api_key"}],
+ "retrieval_schema": [{"name": "top_k"}],
+ },
+ )
+ plugin = _plugin(components=[rag])
+ plugin._runtime_plugin_handler = FakeHandler(plugin)
+ manager.plugins = [plugin]
+
+ engines = await manager.list_knowledge_engines()
+ assert engines[0]["plugin_id"] == "tester/demo"
+ assert engines[0]["capabilities"] == ["ingest", "retrieve"]
+ assert engines[0]["creation_schema"] == {"schema": [{"name": "api_key"}]}
+ assert await manager.get_rag_creation_schema("tester", "demo") == {
+ "schema": [{"name": "api_key"}]
+ }
+ assert await manager.get_rag_retrieval_schema("tester", "demo") == {
+ "schema": [{"name": "top_k"}]
+ }
+ assert await manager.rag_ingest_document(
+ "tester",
+ "demo",
+ {"document_id": "doc-1"},
+ ) == {"ingested": "doc-1"}
+ assert await manager.rag_delete_document("tester", "demo", "kb-1", "doc-1") == {
+ "deleted": ["kb-1", "doc-1"]
+ }
+ assert await manager.rag_on_kb_create(
+ "tester",
+ "demo",
+ "kb-1",
+ {"api_key": "secret"},
+ ) == {"created": "kb-1", "config": {"api_key": "secret"}}
+ assert await manager.rag_on_kb_delete("tester", "demo", "kb-1") == {
+ "deleted_kb": "kb-1"
+ }
+
+ with pytest.raises(ValueError, match="has no KnowledgeEngine component"):
+ manager.plugins = [_plugin(components=[])]
+ await manager.rag_ingest_document("tester", "demo", {})
+
+
+@pytest.mark.asyncio
+async def test_parser_methods_validate_components_and_delegate_to_handler():
+ manager = _manager()
+ parser = _component(
+ "Parser",
+ "pdf",
+ {"supported_mime_types": ["application/pdf"]},
+ )
+ plugin = _plugin(components=[parser])
+ plugin._runtime_plugin_handler = FakeHandler(plugin)
+ manager.plugins = [plugin]
+
+ parsers = await manager.list_parsers()
+ assert parsers[0]["plugin_id"] == "tester/demo"
+ assert parsers[0]["supported_mime_types"] == ["application/pdf"]
+ assert await manager.parse_document(
+ "tester",
+ "demo",
+ {"filename": "a.txt"},
+ b"hello",
+ ) == {"context": {"filename": "a.txt"}, "text": "hello"}
+
+ plugin._runtime_plugin_handler = None
+ with pytest.raises(ValueError, match="is not connected"):
+ await manager.parse_document("tester", "demo", {}, b"")
+
+
+@pytest.mark.asyncio
+async def test_emit_event_should_report_each_emitting_plugin_once():
+ manager = _manager()
+ plugin = _plugin()
+ plugin._runtime_plugin_handler = FakeHandler(plugin)
+ manager.plugins = [plugin]
+ event_context = EventContext(
+ query_id=1,
+ event_name="PersonCommandSent",
+ event=PersonCommandSent(
+ launcher_type="person",
+ launcher_id="launcher",
+ sender_id="sender",
+ command="demo",
+ params=[],
+ text_message="/demo",
+ is_admin=False,
+ ),
+ )
+
+ emitted_plugins, _ = await manager.emit_event(event_context)
+
+ assert emitted_plugins == [plugin]
diff --git a/tests/runtime/test_app.py b/tests/runtime/test_app.py
new file mode 100644
index 00000000..9d16f9fd
--- /dev/null
+++ b/tests/runtime/test_app.py
@@ -0,0 +1,389 @@
+from __future__ import annotations
+
+import argparse
+import asyncio
+
+from langbot_plugin.runtime import app as runtime_app
+
+
+class FakePluginManager:
+ instances = []
+
+ def __init__(self, context):
+ self.context = context
+ self.wait_for_control_connection = None
+ self.calls = []
+ self.handlers = []
+ self.instances.append(self)
+
+ async def ensure_all_plugins_dependencies_installed(self):
+ self.calls.append("ensure_deps")
+
+ async def launch_all_plugins(self):
+ self.calls.append("launch_all")
+
+ async def add_plugin_handler(self, handler):
+ self.calls.append("add_plugin_handler")
+ self.handlers.append(handler)
+
+ async def shutdown_all_plugins(self):
+ self.calls.append("shutdown_all")
+
+
+class FakeServerController:
+ instances = []
+
+ def __init__(self, port=None):
+ self.port = port
+ self.callbacks = []
+ self.instances.append(self)
+
+ async def run(self, callback):
+ self.callbacks.append(callback)
+ await callback(object())
+
+
+class FakeControlHandler:
+ instances = []
+
+ def __init__(self, connection, context):
+ self.connection = connection
+ self.context = context
+ self.calls = []
+ self.instances.append(self)
+
+ async def run(self):
+ self.calls.append("run")
+
+
+class FakePluginHandler:
+ instances = []
+
+ def __init__(self, connection, context, debug_plugin=False):
+ self.connection = connection
+ self.context = context
+ self.debug_plugin = debug_plugin
+ self.instances.append(self)
+
+
+def _args(**overrides):
+ defaults = {
+ "pypi_index_url": "",
+ "pypi_trusted_host": "",
+ "ws_debug_port": 5401,
+ "stdio_control": True,
+ "ws_control_port": 5400,
+ "skip_deps_check": False,
+ "debug_only": False,
+ }
+ defaults.update(overrides)
+ return argparse.Namespace(**defaults)
+
+
+def test_runtime_application_initializes_stdio_control_mode(monkeypatch):
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+
+ app = runtime_app.RuntimeApplication(
+ _args(
+ stdio_control=True,
+ pypi_index_url="https://mirror",
+ pypi_trusted_host="mirror",
+ )
+ )
+
+ assert app._control_connection_mode is runtime_app.ControlConnectionMode.STDIO
+ assert isinstance(app.context.stdio_server, FakeServerController)
+ assert app.context.ws_control_server is None
+ assert app.context.ws_debug_server.port == 5401
+ assert app.context.ws_debug_port == 5401
+ assert runtime_app.os.environ["LANGBOT_PLUGIN_PYPI_INDEX_URL"] == "https://mirror"
+ assert runtime_app.os.environ["LANGBOT_PLUGIN_PYPI_TRUSTED_HOST"] == "mirror"
+
+
+def test_runtime_application_initializes_websocket_control_mode(monkeypatch):
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+
+ app = runtime_app.RuntimeApplication(
+ _args(stdio_control=False, ws_control_port=5500, ws_debug_port=5501)
+ )
+
+ assert app._control_connection_mode is runtime_app.ControlConnectionMode.WS
+ assert app.context.stdio_server is None
+ assert app.context.ws_control_server.port == 5500
+ assert app.context.ws_debug_server.port == 5501
+
+
+async def test_set_control_handler_runs_handler_and_resolves_waiter(monkeypatch):
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+ app = runtime_app.RuntimeApplication(_args())
+ app.context.plugin_mgr.wait_for_control_connection = asyncio.Future()
+ handler = FakeControlHandler(object(), app.context)
+
+ task = app.set_control_handler(handler)
+ await task
+
+ assert app.context.control_handler is handler
+ assert handler.calls == ["run"]
+ assert app.context.plugin_mgr.wait_for_control_connection is None
+
+
+async def test_runtime_application_run_coordinates_servers_and_plugin_manager(
+ monkeypatch,
+):
+ FakePluginManager.instances = []
+ FakeControlHandler.instances = []
+ FakePluginHandler.instances = []
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.control_handler_cls,
+ "ControlConnectionHandler",
+ FakeControlHandler,
+ )
+ monkeypatch.setattr(
+ runtime_app.plugin_handler_cls,
+ "PluginConnectionHandler",
+ FakePluginHandler,
+ )
+ app = runtime_app.RuntimeApplication(_args(stdio_control=True))
+
+ await app.run()
+
+ manager = FakePluginManager.instances[-1]
+ assert manager.calls == [
+ "ensure_deps",
+ "add_plugin_handler",
+ "launch_all",
+ ]
+ assert FakeControlHandler.instances[-1].calls == ["run"]
+ assert FakePluginHandler.instances[-1].debug_plugin is True
+
+
+async def test_runtime_application_run_can_skip_deps_and_plugin_launch(monkeypatch):
+ FakePluginManager.instances = []
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.control_handler_cls,
+ "ControlConnectionHandler",
+ FakeControlHandler,
+ )
+ monkeypatch.setattr(
+ runtime_app.plugin_handler_cls,
+ "PluginConnectionHandler",
+ FakePluginHandler,
+ )
+ app = runtime_app.RuntimeApplication(
+ _args(skip_deps_check=True, debug_only=True)
+ )
+
+ await app.run()
+
+ assert FakePluginManager.instances[-1].calls == ["add_plugin_handler"]
+
+
+async def test_runtime_application_run_uses_websocket_control_server(monkeypatch):
+ FakePluginManager.instances = []
+ FakeControlHandler.instances = []
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.control_handler_cls,
+ "ControlConnectionHandler",
+ FakeControlHandler,
+ )
+ monkeypatch.setattr(
+ runtime_app.plugin_handler_cls,
+ "PluginConnectionHandler",
+ FakePluginHandler,
+ )
+ app = runtime_app.RuntimeApplication(
+ _args(stdio_control=False, skip_deps_check=True, debug_only=True)
+ )
+
+ await app.run()
+
+ assert app.context.ws_control_server.callbacks
+ assert FakeControlHandler.instances[-1].calls == ["run"]
+ assert FakePluginManager.instances[-1].calls == ["add_plugin_handler"]
+
+
+async def test_runtime_application_shutdown_delegates_to_plugin_manager(monkeypatch):
+ FakePluginManager.instances = []
+ monkeypatch.setattr(
+ runtime_app.plugin_mgr_cls,
+ "PluginManager",
+ FakePluginManager,
+ )
+ monkeypatch.setattr(
+ runtime_app.stdio_controller_server,
+ "StdioServerController",
+ FakeServerController,
+ )
+ monkeypatch.setattr(
+ runtime_app.ws_controller_server,
+ "WebSocketServerController",
+ FakeServerController,
+ )
+ app = runtime_app.RuntimeApplication(_args())
+
+ await app.shutdown()
+
+ assert FakePluginManager.instances[-1].calls == ["shutdown_all"]
+
+
+def test_runtime_main_configures_logging_and_runs_application(monkeypatch):
+ calls = []
+
+ class FakeApplication:
+ def __init__(self, args):
+ calls.append(("init", args))
+
+ async def run(self):
+ calls.append(("run",))
+
+ monkeypatch.setattr(
+ runtime_app,
+ "configure_process_logging",
+ lambda: calls.append(("configure_logging",)),
+ )
+ monkeypatch.setattr(runtime_app, "RuntimeApplication", FakeApplication)
+
+ runtime_app.main(_args())
+
+ assert calls == [("configure_logging",), ("init", _args()), ("run",)]
+
+
+def test_runtime_main_handles_cancelled_error(monkeypatch):
+ calls = []
+
+ class FakeApplication:
+ def __init__(self, args):
+ calls.append(("init", args))
+
+ def run(self):
+ return "coroutine"
+
+ monkeypatch.setattr(
+ runtime_app,
+ "configure_process_logging",
+ lambda: calls.append(("configure_logging",)),
+ )
+ monkeypatch.setattr(runtime_app, "RuntimeApplication", FakeApplication)
+ monkeypatch.setattr(
+ runtime_app.asyncio,
+ "run",
+ lambda coroutine: (_ for _ in ()).throw(asyncio.CancelledError()),
+ )
+
+ runtime_app.main(_args())
+
+ assert calls == [("configure_logging",), ("init", _args())]
+
+
+def test_runtime_main_handles_keyboard_interrupt(monkeypatch):
+ calls = []
+
+ class FakeApplication:
+ def __init__(self, args):
+ calls.append(("init", args))
+
+ def run(self):
+ return "coroutine"
+
+ monkeypatch.setattr(
+ runtime_app,
+ "configure_process_logging",
+ lambda: calls.append(("configure_logging",)),
+ )
+ monkeypatch.setattr(runtime_app, "RuntimeApplication", FakeApplication)
+ monkeypatch.setattr(
+ runtime_app.asyncio,
+ "run",
+ lambda coroutine: (_ for _ in ()).throw(KeyboardInterrupt()),
+ )
+
+ runtime_app.main(_args())
+
+ assert calls == [("configure_logging",), ("init", _args())]
diff --git a/tests/utils/test_discovery.py b/tests/utils/test_discovery.py
new file mode 100644
index 00000000..eb12e9a3
--- /dev/null
+++ b/tests/utils/test_discovery.py
@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+import yaml
+
+from langbot_plugin.utils.discover.engine import ComponentDiscoveryEngine
+
+
+def _write_manifest(path, *, kind="Tool", name="demo") -> None:
+ path.write_text(
+ yaml.safe_dump(
+ {
+ "apiVersion": "v1",
+ "kind": kind,
+ "metadata": {
+ "name": name,
+ "label": {"en_US": name.title()},
+ },
+ "spec": {},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+
+def test_load_component_manifest_returns_none_for_non_component_yaml(tmp_path):
+ path = tmp_path / "plain.yaml"
+ path.write_text("name: plain\n", encoding="utf-8")
+
+ engine = ComponentDiscoveryEngine()
+
+ assert engine.load_component_manifest(str(path)) is None
+
+
+def test_load_component_manifest_can_skip_registry_save(tmp_path):
+ path = tmp_path / "tool.yaml"
+ _write_manifest(path)
+ engine = ComponentDiscoveryEngine()
+
+ component = engine.load_component_manifest(str(path), owner="plugin", no_save=True)
+
+ assert component is not None
+ assert component.owner == "plugin"
+ assert component.metadata.name == "demo"
+ assert engine.get_components_by_kind("Tool") == []
+
+
+def test_load_component_manifests_in_dir_respects_depth_and_file_extension(tmp_path):
+ _write_manifest(tmp_path / "root.yaml", kind="Command", name="root")
+ nested = tmp_path / "nested"
+ nested.mkdir()
+ _write_manifest(nested / "nested.yml", kind="Tool", name="nested")
+ too_deep = nested / "too_deep"
+ too_deep.mkdir()
+ _write_manifest(too_deep / "ignored.yaml", kind="Page", name="ignored")
+ (tmp_path / "README.md").write_text("ignore me", encoding="utf-8")
+
+ engine = ComponentDiscoveryEngine()
+ components = engine.load_component_manifests_in_dir(str(tmp_path), max_depth=2)
+
+ assert {component.metadata.name for component in components} == {"root", "nested"}
+ assert [component.metadata.name for component in engine.get_components_by_kind("Command")] == [
+ "root"
+ ]
+ assert [component.metadata.name for component in engine.get_components_by_kind("Tool")] == [
+ "nested"
+ ]
+
+
+def test_discover_blueprint_loads_templates_before_other_component_groups(tmp_path):
+ template = tmp_path / "template.yaml"
+ tool = tmp_path / "tool.yaml"
+ blueprint = tmp_path / "blueprint.yaml"
+ _write_manifest(template, kind="ComponentTemplate", name="base")
+ _write_manifest(tool, kind="Tool", name="weather")
+ blueprint.write_text(
+ yaml.safe_dump(
+ {
+ "apiVersion": "v1",
+ "kind": "Blueprint",
+ "metadata": {"name": "bp", "label": {"en_US": "Blueprint"}},
+ "spec": {
+ "components": {
+ "ComponentTemplate": {"fromFiles": [str(template)]},
+ "Tool": {"fromFiles": [str(tool)]},
+ }
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ engine = ComponentDiscoveryEngine()
+ blueprint_manifest, components = engine.discover_blueprint(str(blueprint))
+
+ assert blueprint_manifest.kind == "Blueprint"
+ assert list(components) == ["ComponentTemplate", "Tool"]
+ assert components["ComponentTemplate"][0].metadata.name == "base"
+ assert components["Tool"][0].metadata.name == "weather"
+
+
+def test_find_components_filters_supplied_component_list(tmp_path):
+ tool = tmp_path / "tool.yaml"
+ command = tmp_path / "command.yaml"
+ _write_manifest(tool, kind="Tool", name="weather")
+ _write_manifest(command, kind="Command", name="weather_cmd")
+ engine = ComponentDiscoveryEngine()
+ components = engine.load_component_manifests_in_dir(str(tmp_path))
+
+ assert [c.kind for c in engine.find_components("Tool", components)] == ["Tool"]
+
+
+def test_component_registry_should_be_isolated_per_engine_instance(tmp_path):
+ path = tmp_path / "tool.yaml"
+ _write_manifest(path)
+
+ first = ComponentDiscoveryEngine()
+ second = ComponentDiscoveryEngine()
+ first.load_component_manifest(str(path), no_save=False)
+
+ assert second.get_components_by_kind("Tool") == []
diff --git a/tests/utils/test_importutil.py b/tests/utils/test_importutil.py
new file mode 100644
index 00000000..fecbde2d
--- /dev/null
+++ b/tests/utils/test_importutil.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import importlib
+import sys
+import textwrap
+
+from langbot_plugin.utils import importutil
+
+
+def test_import_dot_style_dir_imports_python_modules_from_package(tmp_path, monkeypatch):
+ package = tmp_path / "samplepkg"
+ package.mkdir()
+ (package / "__init__.py").write_text("", encoding="utf-8")
+ (package / "alpha.py").write_text("VALUE = 42\n", encoding="utf-8")
+ (package / "notes.txt").write_text("ignored", encoding="utf-8")
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.syspath_prepend(str(tmp_path))
+
+ importutil.import_dot_style_dir("samplepkg")
+
+ assert importlib.import_module("samplepkg.alpha").VALUE == 42
+
+
+def test_import_modules_in_pkg_uses_package_file_location(tmp_path, monkeypatch):
+ package = tmp_path / "anotherpkg"
+ package.mkdir()
+ (package / "__init__.py").write_text("", encoding="utf-8")
+ (package / "beta.py").write_text("FLAG = 'loaded'\n", encoding="utf-8")
+ monkeypatch.syspath_prepend(str(tmp_path))
+ pkg = importlib.import_module("anotherpkg")
+
+ importutil.import_modules_in_pkg(pkg)
+
+ assert sys.modules["anotherpkg.beta"].FLAG == "loaded"
+
+
+def test_import_dir_skips_init_files(tmp_path, monkeypatch):
+ package = tmp_path / "thirdpkg"
+ package.mkdir()
+ (package / "__init__.py").write_text("RAISED = False\n", encoding="utf-8")
+ (package / "gamma.py").write_text(
+ textwrap.dedent(
+ """
+ RESULT = "ok"
+ """
+ ),
+ encoding="utf-8",
+ )
+ monkeypatch.chdir(tmp_path)
+ monkeypatch.syspath_prepend(str(tmp_path))
+
+ importutil.import_dir(str(package))
+
+ assert importlib.import_module("thirdpkg.gamma").RESULT == "ok"
diff --git a/tests/utils/test_platform.py b/tests/utils/test_platform.py
new file mode 100644
index 00000000..97623adb
--- /dev/null
+++ b/tests/utils/test_platform.py
@@ -0,0 +1,9 @@
+import sys
+
+from langbot_plugin.utils.platform import get_platform
+
+
+def test_get_platform_returns_current_sys_platform(monkeypatch):
+ monkeypatch.setattr(sys, "platform", "test-platform")
+
+ assert get_platform() == "test-platform"