mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
274 lines
8.8 KiB
Python
274 lines
8.8 KiB
Python
"""Tests for ContainerRunner and SandboxedAgent."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from openjarvis.agents._stubs import AgentResult, BaseAgent
|
|
from openjarvis.core.events import EventBus, EventType
|
|
from openjarvis.sandbox.runner import (
|
|
_OUTPUT_END,
|
|
_OUTPUT_START,
|
|
ContainerRunner,
|
|
SandboxedAgent,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _wrap_output(payload: dict) -> str:
|
|
return f"{_OUTPUT_START}\n{json.dumps(payload)}\n{_OUTPUT_END}"
|
|
|
|
|
|
def _mock_proc(
|
|
stdout: str = "",
|
|
stderr: str = "",
|
|
returncode: int = 0,
|
|
) -> subprocess.CompletedProcess:
|
|
return subprocess.CompletedProcess(
|
|
args=["docker", "run"],
|
|
returncode=returncode,
|
|
stdout=stdout,
|
|
stderr=stderr,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ContainerRunner tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestContainerRunnerInit:
|
|
def test_defaults(self):
|
|
runner = ContainerRunner()
|
|
assert runner._image == ContainerRunner.DEFAULT_IMAGE
|
|
assert runner._timeout == ContainerRunner.DEFAULT_TIMEOUT
|
|
assert runner._runtime == "docker"
|
|
assert runner._max_concurrent == 5
|
|
|
|
def test_custom_values(self):
|
|
runner = ContainerRunner(
|
|
image="custom:latest",
|
|
timeout=600,
|
|
max_concurrent=10,
|
|
runtime="podman",
|
|
)
|
|
assert runner._image == "custom:latest"
|
|
assert runner._timeout == 600
|
|
assert runner._runtime == "podman"
|
|
assert runner._max_concurrent == 10
|
|
|
|
|
|
class TestBuildDockerArgs:
|
|
def test_basic_args(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value="/usr/bin/docker"):
|
|
args = runner._build_docker_args(
|
|
"test-container", [], None,
|
|
)
|
|
assert "/usr/bin/docker" in args
|
|
assert "run" in args
|
|
assert "--rm" in args
|
|
assert "--name" in args
|
|
assert "test-container" in args
|
|
assert "--network" in args
|
|
assert "none" in args
|
|
assert runner._image in args
|
|
|
|
def test_with_mounts(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value="/usr/bin/docker"):
|
|
args = runner._build_docker_args(
|
|
"test-container", ["/data/project"], None,
|
|
)
|
|
assert "-v" in args
|
|
idx = args.index("-v")
|
|
assert args[idx + 1] == "/data/project:/data/project:ro"
|
|
|
|
def test_with_env(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value="/usr/bin/docker"):
|
|
args = runner._build_docker_args(
|
|
"test-container", [], {"FOO": "bar"},
|
|
)
|
|
assert "-e" in args
|
|
idx = args.index("-e")
|
|
assert args[idx + 1] == "FOO=bar"
|
|
|
|
|
|
class TestContainerRunnerRun:
|
|
def test_successful_run(self):
|
|
runner = ContainerRunner()
|
|
output = _wrap_output({"content": "Hello!"})
|
|
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
|
patch("subprocess.run", return_value=_mock_proc(
|
|
stdout=output,
|
|
)):
|
|
result = runner.run({"prompt": "test"})
|
|
assert result["content"] == "Hello!"
|
|
|
|
def test_timeout(self):
|
|
runner = ContainerRunner(timeout=5)
|
|
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
|
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(
|
|
cmd=["docker"], timeout=5,
|
|
)), \
|
|
patch.object(runner, "stop"):
|
|
result = runner.run({"prompt": "test"})
|
|
assert result["error"] is True
|
|
assert result["error_type"] == "timeout"
|
|
|
|
def test_nonzero_exit(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
|
patch("subprocess.run", return_value=_mock_proc(
|
|
returncode=1, stderr="OOM killed",
|
|
)):
|
|
result = runner.run({"prompt": "test"})
|
|
assert result["error"] is True
|
|
assert "OOM killed" in result["content"]
|
|
|
|
def test_no_sentinel_output(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
|
patch("subprocess.run", return_value=_mock_proc(
|
|
stdout="plain text output",
|
|
)):
|
|
result = runner.run({"prompt": "test"})
|
|
assert result["content"] == "plain text output"
|
|
|
|
|
|
class TestContainerRunnerRuntimeCheck:
|
|
def test_raises_when_runtime_not_found(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value=None):
|
|
with pytest.raises(RuntimeError, match="not found"):
|
|
runner._check_runtime()
|
|
|
|
|
|
class TestCleanupOrphans:
|
|
def test_cleanup_orphans(self):
|
|
runner = ContainerRunner()
|
|
with patch("shutil.which", return_value="/usr/bin/docker"), \
|
|
patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = _mock_proc(stdout="abc123\ndef456")
|
|
runner.cleanup_orphans()
|
|
assert mock_run.call_count == 2 # ps + rm
|
|
|
|
|
|
class TestContainerRunnerParseOutput:
|
|
def test_parse_valid_json(self):
|
|
output = _wrap_output({"content": "result", "metadata": {}})
|
|
result = ContainerRunner._parse_output(output)
|
|
assert result["content"] == "result"
|
|
|
|
def test_parse_no_sentinels(self):
|
|
result = ContainerRunner._parse_output("plain output")
|
|
assert result["content"] == "plain output"
|
|
|
|
def test_parse_invalid_json(self):
|
|
output = f"{_OUTPUT_START}\nnot-json\n{_OUTPUT_END}"
|
|
result = ContainerRunner._parse_output(output)
|
|
assert result.get("parse_error") is True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SandboxedAgent tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSandboxedAgentInit:
|
|
def test_accepts_tools_false(self):
|
|
assert SandboxedAgent.accepts_tools is False
|
|
|
|
def test_agent_id(self):
|
|
assert SandboxedAgent.agent_id == "sandboxed"
|
|
|
|
def test_wraps_agent(self):
|
|
inner = MagicMock(spec=BaseAgent)
|
|
inner.agent_id = "inner"
|
|
inner._engine = MagicMock()
|
|
inner._model = "test-model"
|
|
runner = MagicMock(spec=ContainerRunner)
|
|
|
|
agent = SandboxedAgent(
|
|
inner, runner,
|
|
engine=inner._engine, model="test-model",
|
|
)
|
|
assert agent._wrapped_agent is inner
|
|
assert agent._runner is runner
|
|
|
|
|
|
class TestSandboxedAgentRun:
|
|
def test_delegates_to_runner(self):
|
|
inner = MagicMock(spec=BaseAgent)
|
|
inner.agent_id = "test"
|
|
inner._model = "test-model"
|
|
runner = MagicMock(spec=ContainerRunner)
|
|
runner.run.return_value = {
|
|
"content": "sandbox result",
|
|
"tool_results": [],
|
|
"metadata": {},
|
|
}
|
|
|
|
engine = MagicMock()
|
|
agent = SandboxedAgent(
|
|
inner, runner, engine=engine, model="test-model",
|
|
)
|
|
result = agent.run("hello")
|
|
|
|
assert isinstance(result, AgentResult)
|
|
assert result.content == "sandbox result"
|
|
assert result.turns == 1
|
|
runner.run.assert_called_once()
|
|
|
|
def test_parses_tool_results(self):
|
|
inner = MagicMock(spec=BaseAgent)
|
|
inner.agent_id = "test"
|
|
inner._model = "m"
|
|
runner = MagicMock(spec=ContainerRunner)
|
|
runner.run.return_value = {
|
|
"content": "done",
|
|
"tool_results": [
|
|
{
|
|
"tool_name": "calc",
|
|
"content": "42",
|
|
"success": True,
|
|
},
|
|
],
|
|
}
|
|
|
|
engine = MagicMock()
|
|
agent = SandboxedAgent(
|
|
inner, runner, engine=engine, model="m",
|
|
)
|
|
result = agent.run("compute")
|
|
|
|
assert len(result.tool_results) == 1
|
|
assert result.tool_results[0].tool_name == "calc"
|
|
assert result.tool_results[0].content == "42"
|
|
|
|
def test_emits_events(self):
|
|
inner = MagicMock(spec=BaseAgent)
|
|
inner.agent_id = "test"
|
|
inner._model = "m"
|
|
runner = MagicMock(spec=ContainerRunner)
|
|
runner.run.return_value = {"content": "ok"}
|
|
|
|
bus = EventBus(record_history=True)
|
|
engine = MagicMock()
|
|
agent = SandboxedAgent(
|
|
inner, runner, engine=engine, model="m", bus=bus,
|
|
)
|
|
agent.run("test")
|
|
|
|
types = [e.event_type for e in bus.history]
|
|
assert EventType.AGENT_TURN_START in types
|
|
assert EventType.AGENT_TURN_END in types
|