feat(migration): phase 2.1-2.3 — sandbox dispatch + thin slice tool wrappers
Phase 2.1 — sandbox dispatch helper:
- strix/tools/_sandbox_dispatch.py: post_to_sandbox() centralizes the
host->container HTTP wire format. Connect=10s, read=150s timeouts mirror
legacy executor.py. 50 MB response cap (C18) prevents OOM from a runaway
tool. All errors surface as {"error": str} so the model can recover
instead of the run dying.
Phase 2.2 — C6 lock-protected JSONL writes:
- strix/tools/notes/notes_actions.py: notes.jsonl appends are now wrapped
in _notes_lock so concurrent agents can't interleave half-written lines.
Regression test in test_notes_jsonl_concurrency.py verifies 1000 parallel
writes produce exactly 1000 valid JSON lines.
Phase 2.3 — thin-slice SDK wrappers (think + todo + notes):
- strix/tools/_legacy_adapter.py: LegacyAgentStateAdapter shim — exposes
just enough surface (.agent_id) for legacy tools that close over
agent_state, sourced from ctx.context['agent_id'].
- strix/tools/thinking/thinking_sdk_tools.py: 1 tool (think).
- strix/tools/todo/todo_sdk_tools.py: 6 tools (create/list/update/done/
pending/delete) with bulk-form preserved.
- strix/tools/notes/notes_sdk_tools.py: 5 tools (create/list/get/update/
delete) with asyncio.to_thread around the lock-protected file I/O.
Tests: 22 new tests pass (10 sandbox dispatch + 2 concurrency + 10 SDK
local). Full suite still green.
Per-file ruff ignores added for SDK wrapper files: TC002 (RunContextWrapper
must be runtime-importable because the SDK calls get_type_hints() to
derive the JSON schema) and PLR0911 (sandbox dispatch's 10 short-circuit
returns are intentional, each a distinct documented failure mode).
Refs: PLAYBOOK.md §3.4, AUDIT_R3.md C6/C18.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""Phase 2.1 smoke tests for the sandbox dispatch helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from strix.tools._sandbox_dispatch import post_to_sandbox
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Ctx:
|
||||
"""Stand-in for ``RunContextWrapper``. Only ``.context`` is touched."""
|
||||
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
def _ok_ctx(**overrides: Any) -> _Ctx:
|
||||
base: dict[str, Any] = {
|
||||
"tool_server_host_port": 48081,
|
||||
"sandbox_token": "test-bearer",
|
||||
"agent_id": "agent-1",
|
||||
}
|
||||
base.update(overrides)
|
||||
return _Ctx(context=base)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_context_returns_error() -> None:
|
||||
"""If ``ctx.context`` isn't a dict, return error — never raise."""
|
||||
ctx = _Ctx(context="not a dict")
|
||||
result = await post_to_sandbox(ctx, "browser_action", {"action": "launch"})
|
||||
assert "error" in result
|
||||
assert "context" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_port_or_token_returns_error() -> None:
|
||||
ctx = _Ctx(context={"sandbox_token": "tok"}) # no port
|
||||
result = await post_to_sandbox(ctx, "x", {})
|
||||
assert "error" in result
|
||||
assert "tool server port" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_response_returned_as_dict(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_post(
|
||||
self: httpx.AsyncClient,
|
||||
url: str,
|
||||
*,
|
||||
json: dict[str, Any] | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> httpx.Response:
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
captured["headers"] = headers
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json={"result": "ok"},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
|
||||
result = await post_to_sandbox(_ok_ctx(), "terminal_execute", {"command": "ls"})
|
||||
assert result == {"result": "ok"}
|
||||
assert captured["url"] == "http://127.0.0.1:48081/execute"
|
||||
assert captured["json"] == {
|
||||
"agent_id": "agent-1",
|
||||
"tool_name": "terminal_execute",
|
||||
"kwargs": {"command": "ls"},
|
||||
}
|
||||
assert captured["headers"]["Authorization"] == "Bearer test-bearer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_401_returns_auth_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_post(
|
||||
self: httpx.AsyncClient,
|
||||
url: str,
|
||||
**_: Any,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=401,
|
||||
text="forbidden",
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
||||
assert "error" in result
|
||||
assert "authorization" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_5xx_returns_http_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_post(
|
||||
self: httpx.AsyncClient,
|
||||
url: str,
|
||||
**_: Any,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=503,
|
||||
text="server fell over",
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
|
||||
assert "error" in result
|
||||
assert "503" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_returns_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
||||
raise httpx.ReadTimeout("read timeout")
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "python_action", {})
|
||||
assert "error" in result
|
||||
assert "timed out" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_error_returns_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def fake_post(*_args: Any, **_kwargs: Any) -> httpx.Response:
|
||||
raise httpx.ConnectError("refused")
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
||||
assert "error" in result
|
||||
assert "connection" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_too_large_capped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""C18 (AUDIT_R3): response > 50MB returns error, doesn't OOM."""
|
||||
|
||||
async def fake_post(
|
||||
self: httpx.AsyncClient,
|
||||
url: str,
|
||||
**_: Any,
|
||||
) -> httpx.Response:
|
||||
# Construct a response with a >50MB body. Use a string trick —
|
||||
# httpx.Response stores .content directly; we make it look huge.
|
||||
big = b"x" * (51 * 1024 * 1024)
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
content=big,
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "browser_action", {})
|
||||
assert "error" in result
|
||||
assert "too large" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_json_response_returns_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post(
|
||||
self: httpx.AsyncClient,
|
||||
url: str,
|
||||
**_: Any,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
text="hello not json",
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
||||
assert "error" in result
|
||||
assert "non-json" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_object_json_returns_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def fake_post(
|
||||
self: httpx.AsyncClient,
|
||||
url: str,
|
||||
**_: Any,
|
||||
) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=200,
|
||||
json=["a", "list", "not", "an", "object"],
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx.AsyncClient, "post", fake_post)
|
||||
result = await post_to_sandbox(_ok_ctx(), "x", {})
|
||||
assert "error" in result
|
||||
assert "non-object" in result["error"].lower()
|
||||
Reference in New Issue
Block a user