diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 526219705..515cbb2eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,28 @@ on: branches: [main] paths-ignore: - docs/** + - .agent/** + - .github/ISSUE_TEMPLATE/** + - AGENTS.md + - CLAUDE.md + - COMMUNICATION.md + - CONTRIBUTING.md + - README.md + - SECURITY.md + - webui/README.md pull_request: branches: [main] paths-ignore: - docs/** + - .agent/** + - .github/ISSUE_TEMPLATE/** + - AGENTS.md + - CLAUDE.md + - COMMUNICATION.md + - CONTRIBUTING.md + - README.md + - SECURITY.md + - webui/README.md concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -68,14 +86,18 @@ jobs: os: ubuntu-latest python-version: "3.11" coverage: false + pytest_args: "" - name: latest, 3.14 + coverage os: ubuntu-latest python-version: "3.14" coverage: true + pytest_args: "" - name: Windows, 3.14 os: windows-latest python-version: "3.14" coverage: false + # Keep each test file in one worker while using both hosted-runner cores. + pytest_args: "-n 2 --dist loadfile" steps: - uses: actions/checkout@v4 @@ -98,6 +120,9 @@ jobs: - name: Install channel dependencies run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels + - name: Verify dependency consistency + run: uv pip check + # Channel requirements live in manifests rather than uv.lock. Avoid a # later uv run sync pruning the packages installed by the previous step. - name: Lint with ruff @@ -115,6 +140,7 @@ jobs: if: ${{ !matrix.coverage }} run: >- uv run --no-sync python -m pytest + ${{ matrix.pytest_args }} --durations=25 --durations-min=1.0 webui: diff --git a/conftest.py b/conftest.py index fafe649b4..a7c568839 100644 --- a/conftest.py +++ b/conftest.py @@ -9,6 +9,17 @@ from collections.abc import Iterator import certifi import pytest +from loguru import logger + + +@pytest.fixture(autouse=True) +def _isolate_nanobot_log_activation() -> Iterator[None]: + """Keep CLI log settings from leaking into later tests in the same process.""" + logger.enable("nanobot") + try: + yield + finally: + logger.enable("nanobot") @pytest.fixture(scope="session", autouse=True) diff --git a/pyproject.toml b/pyproject.toml index da5dede45..129fbe33b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,8 @@ dependencies = [ "anthropic>=0.45.0,<1.0.0", "pydantic>=2.12.0,<3.0.0", "pydantic-settings>=2.12.0,<3.0.0", - "websockets>=16.0,<17.0", + # Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16. + "websockets>=15.0,<17.0", "websocket-client>=1.9.0,<2.0.0", "httpx>=0.28.0,<1.0.0", "ddgs>=9.5.5,<10.0.0", @@ -84,13 +85,14 @@ pdf = [ "pypdf>=5.0.0,<6.0.0", ] olostep = [ - "olostep>=0.1.0", + "olostep>=0.1.0; python_version < '3.14'", ] dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", "aiohttp>=3.9.0,<4.0.0", "pytest-cov>=6.0.0,<7.0.0", + "pytest-xdist>=3.8.0,<4.0.0", "ruff>=0.1.0", "pymupdf>=1.25.0", "pypdf>=5.0.0,<6.0.0", diff --git a/scripts/install_channel_dependencies.py b/scripts/install_channel_dependencies.py index 73c9f1755..858621ca9 100644 --- a/scripts/install_channel_dependencies.py +++ b/scripts/install_channel_dependencies.py @@ -5,8 +5,69 @@ from __future__ import annotations import sys from collections.abc import Sequence +from nanobot.channels.plugin import ChannelPlugin from nanobot.channels.registry import discover_plugins -from nanobot.optional_features import ensure_enabled_channel_dependencies +from nanobot.optional_features import ( + ensure_enabled_channel_dependencies, + extra_installed, + install_args_for_extra, + install_extra, +) + +_DEPENDENCY_FAILURE = "Channel dependencies could not be installed. Check gateway logs." + + +def ensure_repository_channel_dependencies( + names: set[str], + plugins: dict[str, ChannelPlugin], +) -> dict[str, str]: + """Batch repository dependency installs, then verify every channel independently.""" + requirements_by_name: dict[str, list[str]] = {} + pending: dict[str, list[str]] = {} + install_args: list[str] = [] + seen_args: set[str] = set() + + for name in sorted(names): + plugin = plugins.get(name) + if plugin is None: + continue + dependencies = list(plugin.dependencies) + if not dependencies: + continue + requirements_by_name[name] = dependencies + if extra_installed(name, dependencies): + continue + pending[name] = dependencies + channel_args, _label = install_args_for_extra(name, dependencies) + for requirement in channel_args: + if requirement not in seen_args: + seen_args.add(requirement) + install_args.append(requirement) + + if not pending: + return {} + + if install_args: + result = install_extra("channel-dependencies", install_args) + if result.ok: + unresolved = { + name + for name, dependencies in requirements_by_name.items() + if not extra_installed(name, dependencies) + } + else: + unresolved = set(requirements_by_name) + else: + unresolved = set(requirements_by_name) + + if not unresolved: + return {} + + failures = ensure_enabled_channel_dependencies(unresolved, plugins) + for name, dependencies in requirements_by_name.items(): + if name not in failures and not extra_installed(name, dependencies): + failures[name] = _DEPENDENCY_FAILURE + return failures def main(argv: Sequence[str] | None = None) -> int: @@ -26,7 +87,7 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"Unknown channels: {', '.join(unknown)}", file=sys.stderr) return 2 - failures = ensure_enabled_channel_dependencies(names, plugins) + failures = ensure_repository_channel_dependencies(names, plugins) for name, message in sorted(failures.items()): print(f"{name}: {message}", file=sys.stderr) return 1 if failures else 0 diff --git a/tests/agent/test_memory_store.py b/tests/agent/test_memory_store.py index bf8f4c727..052743156 100644 --- a/tests/agent/test_memory_store.py +++ b/tests/agent/test_memory_store.py @@ -308,19 +308,17 @@ class TestAppendHistoryHardCap: entry = store.read_unprocessed_history(since_cursor=0)[0] assert len(entry["content"]) <= _HISTORY_ENTRY_HARD_CAP + 50 - def test_oversize_warning_is_emitted_once(self, store, caplog): + def test_oversize_warning_is_emitted_once(self, store, monkeypatch): """Repeated oversized writes should warn only on the first occurrence.""" - from loguru import logger as loguru_logger - records: list[str] = [] - handler_id = loguru_logger.add(lambda m: records.append(m), level="WARNING") - try: - huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1) - store.append_history(huge) - store.append_history(huge) - store.append_history(huge) - finally: - loguru_logger.remove(handler_id) + monkeypatch.setattr( + "nanobot.agent.memory.logger.warning", + lambda message, *args: records.append(message.format(*args)), + ) + huge = "x" * (_HISTORY_ENTRY_HARD_CAP + 1) + store.append_history(huge) + store.append_history(huge) + store.append_history(huge) oversize_warnings = [r for r in records if "exceeds" in r and "chars" in r] assert len(oversize_warnings) == 1 diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index 7e61257b8..a57160ea2 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -1492,7 +1492,7 @@ def test_repository_dependency_installer_selects_all_channel_manifests(monkeypat monkeypatch.setattr(dependencies, "discover_plugins", lambda: plugins) monkeypatch.setattr( dependencies, - "ensure_enabled_channel_dependencies", + "ensure_repository_channel_dependencies", lambda names, discovered: prepared.append((names, discovered)) or {}, ) @@ -1500,6 +1500,191 @@ def test_repository_dependency_installer_selects_all_channel_manifests(monkeypat assert prepared == [(set(plugins), plugins)] +def test_repository_dependency_installer_batches_missing_manifests(monkeypatch): + from nanobot.optional_features import InstallResult + from scripts import install_channel_dependencies as dependencies + + plugins = { + "second": ChannelPlugin( + name="second", + display_name="Second", + runtime="missing.second.runtime:SecondChannel", + dependencies=("shared-sdk>=1", "second-sdk>=2"), + ), + "first": ChannelPlugin( + name="first", + display_name="First", + runtime="missing.first.runtime:FirstChannel", + dependencies=("first-sdk>=1", "shared-sdk>=1"), + ), + "ready": ChannelPlugin( + name="ready", + display_name="Ready", + runtime="missing.ready.runtime:ReadyChannel", + dependencies=("ready-sdk>=1",), + ), + } + batch_installed = False + installs: list[tuple[str, list[str]]] = [] + + def extra_installed(name: str, _requirements: list[str]) -> bool: + return name == "ready" or batch_installed + + def install_extra(name: str, requirements: list[str]) -> InstallResult: + nonlocal batch_installed + installs.append((name, requirements)) + batch_installed = True + return InstallResult(True, name, ["pip"]) + + monkeypatch.setattr(dependencies, "extra_installed", extra_installed) + monkeypatch.setattr(dependencies, "install_extra", install_extra) + monkeypatch.setattr( + dependencies, + "ensure_enabled_channel_dependencies", + lambda _names, _plugins: pytest.fail("verified batch must not use the fallback"), + ) + + failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins) + + assert failures == {} + assert installs == [ + ( + "channel-dependencies", + ["first-sdk>=1", "shared-sdk>=1", "second-sdk>=2"], + ) + ] + + +def test_repository_dependency_installer_falls_back_after_batch_failure(monkeypatch): + from nanobot.optional_features import InstallResult + from scripts import install_channel_dependencies as dependencies + + plugins = { + name: ChannelPlugin( + name=name, + display_name=name.title(), + runtime=f"missing.{name}.runtime:Channel", + dependencies=(f"{name}-sdk>=1",), + ) + for name in ("first", "second") + } + fallbacks: list[set[str]] = [] + fallback_finished = False + + def extra_installed(name: str, _requirements: list[str]) -> bool: + return fallback_finished and name == "first" + + def fallback(names: set[str], _plugins: dict[str, ChannelPlugin]) -> dict[str, str]: + nonlocal fallback_finished + fallbacks.append(names) + fallback_finished = True + return {"second": "install failed"} + + monkeypatch.setattr(dependencies, "extra_installed", extra_installed) + monkeypatch.setattr( + dependencies, + "install_extra", + lambda name, _requirements: InstallResult(False, name, ["pip"]), + ) + monkeypatch.setattr( + dependencies, + "ensure_enabled_channel_dependencies", + fallback, + ) + + failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins) + + assert failures == {"second": "install failed"} + assert fallbacks == [set(plugins)] + + +def test_repository_dependency_installer_rechecks_each_channel_after_batch(monkeypatch): + from nanobot.optional_features import InstallResult + from scripts import install_channel_dependencies as dependencies + + plugins = { + name: ChannelPlugin( + name=name, + display_name=name.title(), + runtime=f"missing.{name}.runtime:Channel", + dependencies=(f"{name}-sdk>=1",), + ) + for name in ("first", "second") + } + batch_finished = False + fallback_finished = False + fallbacks: list[set[str]] = [] + + def extra_installed(name: str, _requirements: list[str]) -> bool: + if fallback_finished: + return True + if batch_finished: + return name == "second" + return name == "first" + + def install_extra(name: str, _requirements: list[str]) -> InstallResult: + nonlocal batch_finished + batch_finished = True + return InstallResult(True, name, ["pip"]) + + def fallback(names: set[str], _plugins: dict[str, ChannelPlugin]) -> dict[str, str]: + nonlocal fallback_finished + fallbacks.append(names) + fallback_finished = True + return {} + + monkeypatch.setattr(dependencies, "extra_installed", extra_installed) + monkeypatch.setattr(dependencies, "install_extra", install_extra) + monkeypatch.setattr( + dependencies, + "ensure_enabled_channel_dependencies", + fallback, + ) + + failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins) + + assert failures == {} + assert fallbacks == [{"first"}] + + +def test_repository_dependency_installer_reports_conflict_after_fallback(monkeypatch): + from nanobot.optional_features import InstallResult + from scripts import install_channel_dependencies as dependencies + + plugins = { + name: ChannelPlugin( + name=name, + display_name=name.title(), + runtime=f"missing.{name}.runtime:Channel", + dependencies=(f"{name}-sdk>=1",), + ) + for name in ("first", "second") + } + fallback_finished = False + + def extra_installed(name: str, _requirements: list[str]) -> bool: + return fallback_finished and name == "second" + + def fallback(_names: set[str], _plugins: dict[str, ChannelPlugin]) -> dict[str, str]: + nonlocal fallback_finished + fallback_finished = True + return {} + + monkeypatch.setattr(dependencies, "extra_installed", extra_installed) + monkeypatch.setattr( + dependencies, + "install_extra", + lambda name, _requirements: InstallResult(False, name, ["pip"]), + ) + monkeypatch.setattr(dependencies, "ensure_enabled_channel_dependencies", fallback) + + failures = dependencies.ensure_repository_channel_dependencies(set(plugins), plugins) + + assert failures == { + "first": "Channel dependencies could not be installed. Check gateway logs." + } + + def test_repository_dependency_installer_rejects_unknown_channel(monkeypatch, capsys): from scripts import install_channel_dependencies as dependencies @@ -1520,7 +1705,7 @@ def test_repository_dependency_installer_propagates_install_failure(monkeypatch, monkeypatch.setattr(dependencies, "discover_plugins", lambda: {"demo": plugin}) monkeypatch.setattr( dependencies, - "ensure_enabled_channel_dependencies", + "ensure_repository_channel_dependencies", lambda _names, _plugins: {"demo": "dependency install failed"}, ) @@ -2388,6 +2573,12 @@ def test_optional_dependency_metadata_for_enable(): ] assert deps["pdf"] == ["pypdf>=5.0.0,<6.0.0"] assert deps["langfuse"] == ["langfuse>=3.0.0,<4.0.0"] + assert deps["olostep"] == ["olostep>=0.1.0; python_version < '3.14'"] + expected_olostep_args = [] if sys.version_info >= (3, 14) else ["olostep>=0.1.0"] + assert optional_features.install_args_for_extra("olostep", deps["olostep"]) == ( + expected_olostep_args, + "olostep support", + ) channel_names = { "dingtalk", "discord", diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 85b46514c..898b35e6c 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -35,6 +35,10 @@ from nanobot.webui.metadata import ( runner = CliRunner() +def _without_rendered_line_breaks(output: str) -> str: + return "".join(output.splitlines()) + + def test_proactive_websocket_delivery_gets_fresh_turn_id() -> None: metadata = { "webui": True, @@ -2150,7 +2154,7 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup( assert result.exit_code == 1 assert "provider/model setup is incomplete" in result.stdout - assert "Settings → Models" in result.stdout + assert "Settings → Models" in _without_rendered_line_breaks(result.stdout) assert "nanobot onboard --wizard" in result.stdout assert "nanobot status --config" in result.stdout assert config_file.name in result.stdout diff --git a/tests/cli/test_config_diagnostics.py b/tests/cli/test_config_diagnostics.py index 99a215f74..93c6da5b0 100644 --- a/tests/cli/test_config_diagnostics.py +++ b/tests/cli/test_config_diagnostics.py @@ -174,7 +174,7 @@ def test_status_reports_missing_provider_with_shortest_setup_routes(tmp_path) -> assert result.exit_code == 0 assert "Agent: ✗" in result.stdout assert "No provider is configured for model" in result.stdout - assert "Settings → Models" in result.stdout + assert "Settings → Models" in _without_rendered_line_breaks(result.stdout) assert "nanobot onboard --wizard" in result.stdout assert "nanobot status --config" in result.stdout diff --git a/tests/tools/test_exec_session_tools.py b/tests/tools/test_exec_session_tools.py index 5300bb14f..138389c3d 100644 --- a/tests/tools/test_exec_session_tools.py +++ b/tests/tools/test_exec_session_tools.py @@ -334,27 +334,36 @@ def test_write_stdin_can_wait_for_expected_output(tmp_path): def test_write_stdin_wait_for_reports_timeout_without_killing_session(tmp_path): - async def run() -> tuple[str, str, str]: + async def run() -> tuple[str, str, str, str]: manager = ExecSessionManager() exec_tool = ExecTool(working_dir=str(tmp_path), timeout=5, session_manager=manager) stdin_tool = WriteStdinTool(manager=manager) - command = _waiting_shell_command("booting") + command = _waiting_shell_command("booting", delayed="ready") - initial = await exec_tool.execute(command=command, yield_time_ms=100) + initial = await exec_tool.execute(command=command, yield_time_ms=0) sid = _session_id(initial) + # Synchronize on an stdin-gated marker before exercising the immediate timeout below. + ready = await stdin_tool.execute( + session_id=sid, + chars="\n", + wait_for="ready", + wait_timeout_ms=10000, + yield_time_ms=0, + ) waited = await stdin_tool.execute( session_id=sid, wait_for="never-ready", - wait_timeout_ms=200, + wait_timeout_ms=0, yield_time_ms=0, ) cleanup = await stdin_tool.execute(session_id=sid, terminate=True, yield_time_ms=0) - return initial, waited, cleanup + return initial, ready, waited, cleanup - initial, waited, cleanup = asyncio.run(run()) + initial, ready, waited, cleanup = asyncio.run(run()) assert "Process running" in initial - assert "booting" in initial + waited + assert "booting" in initial + ready + assert "ready" in ready assert "Process running" in waited assert "Wait target not observed: 'never-ready'" in waited assert "Session terminated." in cleanup