feat: prepare EvoScientist 0.3.0
Docker / build (push) Has been cancelled
Build / build (push) Has been cancelled
Lint / ruff (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.11) (push) Has been cancelled
Test / pytest (ubuntu-latest, 3.12) (push) Has been cancelled
Test / pytest (windows-latest, 3.11) (push) Has been cancelled
Test / pytest (windows-latest, 3.12) (push) Has been cancelled

Add bounded document ingestion, controlled web search, recoverable session support, subagent timeouts, and the native sandbox runtime contract. Unify package versioning and add release-focused regression coverage.
This commit is contained in:
m4
2026-09-03 06:55:56 +08:00
parent ce0bec1f91
commit c683f6e739
35 changed files with 2806 additions and 67 deletions
@@ -15,6 +15,7 @@ from types import SimpleNamespace
import pytest
from EvoScientist.llm.contracts import EvoRuntimeError
from EvoScientist.llm.errors import (
AgentControlError,
ModelToolProtocolError,
@@ -169,6 +170,16 @@ class TestNormalize:
assert _normalize(req, error) is None
def test_stable_runtime_error_passes_through(self):
req = _request(_openai_model())
error = EvoRuntimeError(
"UPSTREAM_RATE_LIMITED",
"模型服务请求频率超限,请稍后重试或切换模型。",
details=({"http_status": 429},),
)
assert _normalize(req, error) is None
# ---------------------------------------------------------------------------
# _is_provider_error — used by tool selector to distinguish provider
@@ -397,6 +408,23 @@ class TestMiddleware:
assert excinfo.value.code == "MODEL_TOOL_PROTOCOL_INVALID"
assert excinfo.value.fallbackable is True
def test_awrap_preserves_stable_runtime_error_identity(self):
raised = EvoRuntimeError(
"UPSTREAM_RATE_LIMITED",
"模型服务请求频率超限,请稍后重试或切换模型。",
details=({"http_status": 429},),
)
async def handler(_req):
raise raised
req = _request(_openai_model())
with pytest.raises(EvoRuntimeError) as excinfo:
self._run_awrap(ErrorNormalizationMiddleware(), req, handler)
assert excinfo.value is raised
assert excinfo.value.code == "UPSTREAM_RATE_LIMITED"
def test_awrap_wraps_any_exception_from_recognized_model(self):
"""Any exception raised inside a call to a provider-recognized
model gets wrapped — including builtins like ``RuntimeError``.
+36 -2
View File
@@ -4,6 +4,7 @@ import httpx
import pytest
from langchain_core.messages import HumanMessage
from EvoScientist.llm.contracts import EvoRuntimeError
from EvoScientist.llm.gateway_proxy import GatewayProxyChatModel
@@ -43,6 +44,18 @@ class _FakeClient:
return _FakeStream(self._lines)
def test_runtime_error_repr_preserves_only_stable_code():
error = EvoRuntimeError(
"UPSTREAM_RATE_LIMITED",
"safe display message",
details=({"provider_request": "must-not-persist"},),
)
assert repr(error) == "EvoRuntimeError(code='UPSTREAM_RATE_LIMITED')"
assert "safe display message" not in repr(error)
assert "must-not-persist" not in repr(error)
@pytest.mark.anyio
async def test_astream_yields_chunks_from_sse(monkeypatch):
model = GatewayProxyChatModel(
@@ -52,7 +65,7 @@ async def test_astream_yields_chunks_from_sse(monkeypatch):
)
msg = {"type": "AIMessageChunk", "data": {"content": "hello"}}
lines = [
f'data: {json.dumps({"delta": {"message": msg}})}\n',
f"data: {json.dumps({'delta': {'message': msg}})}\n",
'data: {"delta": {"message": {"type": "AIMessageChunk", "data": {"content": " world"}}}}\n',
"data: [DONE]\n",
]
@@ -88,7 +101,7 @@ async def test_astream_roundtrips_streaming_tool_call_chunks(monkeypatch):
],
},
}
lines = [f'data: {json.dumps({"delta": {"message": msg}})}\n', "data: [DONE]\n"]
lines = [f"data: {json.dumps({'delta': {'message': msg}})}\n", "data: [DONE]\n"]
fake = _FakeClient(lines)
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: fake)
@@ -114,3 +127,24 @@ async def test_astream_raises_on_missing_done(monkeypatch):
with pytest.raises(RuntimeError, match="AI4SCI_MODEL_STREAM_INCOMPLETE"):
_ = [c async for c in model._astream([HumanMessage(content="hi")])]
@pytest.mark.anyio
async def test_astream_projects_gateway_error_frame(monkeypatch):
model = GatewayProxyChatModel(
gateway_url="http://gw",
run_id="run-1",
envelope_signature="sig",
)
lines = [
'data: {"type":"error","code":"UPSTREAM_RATE_LIMITED",'
'"status":429,"message":"模型服务请求频率超限,请稍后重试或切换模型。"}\n'
]
fake = _FakeClient(lines)
monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: fake)
with pytest.raises(EvoRuntimeError) as exc_info:
_ = [c async for c in model._astream([HumanMessage(content="hi")])]
assert exc_info.value.code == "UPSTREAM_RATE_LIMITED"
assert exc_info.value.details == ({"http_status": 429},)
+57
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import json
import sys
import types
from pathlib import Path
import pytest
@@ -21,6 +23,18 @@ def _installation(tmp_path: Path) -> sandbox.NativeSandboxInstallation:
)
def test_existing_read_paths_resolve_and_deduplicate_symlink_aliases(tmp_path: Path):
usr = tmp_path / "usr"
usr.mkdir()
bin_alias = tmp_path / "bin"
bin_alias.symlink_to(usr, target_is_directory=True)
missing = tmp_path / "missing"
paths = sandbox._existing_resolved_paths((str(usr), str(bin_alias), str(missing)))
assert paths == (str(usr.resolve()),)
def test_policy_denies_root_and_only_writes_scope_and_command_tmp(tmp_path: Path):
files = tmp_path / "files"
command_tmp = tmp_path / "runtime" / "tmp" / "run"
@@ -38,6 +52,7 @@ def test_policy_denies_root_and_only_writes_scope_and_command_tmp(tmp_path: Path
"/dev/null",
]
assert policy["filesystem"]["denyWrite"] == [
str(files / "uploads"),
"/tmp/claude",
"/private/tmp/claude",
"/dev/tty",
@@ -50,6 +65,48 @@ def test_policy_denies_root_and_only_writes_scope_and_command_tmp(tmp_path: Path
assert "control" not in json.dumps(policy)
def test_weaker_nested_mode_requires_explicit_environment_opt_in(tmp_path: Path, monkeypatch):
monkeypatch.delenv("EVOSCIENTIST_NATIVE_SANDBOX_WEAKER_NESTED", raising=False)
files = tmp_path / "files"
command_tmp = tmp_path / "runtime" / "tmp" / "run"
files.mkdir()
command_tmp.mkdir(parents=True)
installation = _installation(tmp_path)
assert sandbox._sandbox_settings(installation, files, command_tmp)[
"enableWeakerNestedSandbox"
] is False
monkeypatch.setenv("EVOSCIENTIST_NATIVE_SANDBOX_WEAKER_NESTED", "true")
assert sandbox._sandbox_settings(installation, files, command_tmp)[
"enableWeakerNestedSandbox"
] is True
def test_network_preflight_probe_accepts_kernel_denied_unix_socket(monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
class FakeSocket:
def __init__(self, family=None, *_args):
if family == 1:
raise PermissionError("blocked by seccomp")
def connect_ex(self, _address):
return 1
def close(self):
return None
fake_socket = types.SimpleNamespace(
AF_UNIX=1,
socket=lambda family=None, *args: FakeSocket(family, *args),
)
monkeypatch.setitem(sys.modules, "socket", fake_socket)
namespace: dict[str, object] = {}
exec(sandbox._network_preflight_probe(1234, Path("/blocked.sock")), namespace)
def test_clean_environment_does_not_inherit_secrets(tmp_path: Path, monkeypatch):
command_tmp = tmp_path / "tmp"
(command_tmp / "home").mkdir(parents=True)
@@ -0,0 +1,62 @@
from __future__ import annotations
import runpy
from collections.abc import Callable
from pathlib import Path
from typing import cast
import pytest
PATCH_SCRIPT = Path(__file__).parents[1] / "runtime" / "native-sandbox" / "patch_merged_usr.py"
def test_patch_skips_only_symlink_aliases_covered_by_read_allow(tmp_path: Path):
namespace = runpy.run_path(str(PATCH_SCRIPT))
patch_file = cast(Callable[[Path], None], namespace["patch_file"])
source = tmp_path / "linux-sandbox-utils.js"
source.write_text(
"""function pushReadDenyDirMounts(args, normalizedPath, allowedWritePaths, readAllowPaths) {
const denySep = normalizedPath === '/' ? '/' : normalizedPath + '/';
args.push('--tmpfs', normalizedPath);
for (const writePath of allowedWritePaths) {
if (writePath.startsWith(denySep) || writePath === normalizedPath) {
args.push('--bind', writePath, writePath);
}
}
for (const allowPath of readAllowPaths) {
if (allowPath.startsWith(denySep) || allowPath === normalizedPath) {
if (!fs.existsSync(allowPath)) {
continue;
}
if (allowedWritePaths.some(w => (w.startsWith(denySep) || w === normalizedPath) &&
(allowPath === w || allowPath.startsWith(w + '/')))) {
continue;
}
args.push('--ro-bind', allowPath, allowPath);
logForDebugging(`[Sandbox Linux] Re-allowed read access within denied region: ${allowPath}`);
}
}
}
const rootSkip = new Set(['proc', 'dev', 'sys']);
for (const p of readConfig?.denyOnly || []) {
if (normalizePathForSandbox(p) === '/') {
for (const child of fs.readdirSync('/')) {
if (!rootSkip.has(child))
readDenyPaths.push('/' + child);
}
}
""",
encoding="utf-8",
)
patch_file(source)
patched = source.read_text(encoding="utf-8")
assert "isSymbolicLink()" in patched
assert "readAllowPaths.some" in patched
assert "resolved.startsWith(allowPath + '/')" in patched
assert "args.push('--remount-ro', normalizedPath)" in patched
assert "!allowedWritePaths.includes(normalizedPath)" in patched
with pytest.raises(RuntimeError, match="already patched"):
patch_file(source)
+17
View File
@@ -38,6 +38,23 @@ class TestGetSystemPrompt:
result = get_system_prompt()
assert "Shell Execution Guidelines" in result
def test_contains_safe_archive_and_sqlite_programming_contracts(self):
result = get_system_prompt(native_web_sandbox=True)
assert "never use `extractall`" in result
assert "mode=ro&immutable=1" in result
assert "PRAGMA query_only=ON" in result
assert "Never execute files extracted from an archive" in result
assert "Do not modify original files under `uploads/`" in result
def test_distinguishes_live_web_search_from_local_memory_search(self):
result = get_system_prompt(native_web_sandbox=True)
assert "tavily_search" in result
assert "search_observations" in result
assert "local memory" in result.lower()
assert "Do not use `execute`, `curl`, or `httpx`" in result
def test_contains_delegation(self):
result = get_system_prompt()
assert "Sub-Agent Delegation" in result
+37
View File
@@ -1,5 +1,9 @@
from __future__ import annotations
import httpx
import pytest
from EvoScientist.llm.contracts import EvoRuntimeError
from EvoScientist.middleware import recoverable_tools
@@ -51,3 +55,36 @@ def test_evomemory_never_falls_back_to_parent_model_proxy(monkeypatch):
assert proxy is None
assert metadata["run_kind"] == "evomemory_turn_worker"
@pytest.mark.asyncio
async def test_tool_effect_gateway_error_preserves_machine_code(monkeypatch):
class Client:
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return None
async def post(self, url, json):
return httpx.Response(
409,
json={"detail": {"code": "RUN_FENCE_LOST"}},
request=httpx.Request("POST", url),
)
monkeypatch.setattr(recoverable_tools.httpx, "AsyncClient", lambda **_: Client())
with pytest.raises(EvoRuntimeError) as exc_info:
await recoverable_tools._post(
{
"gateway_url": "http://gateway",
"run_id": "run-1",
"envelope_signature": "signature",
},
"prepare",
{},
)
assert exc_info.value.code == "RUN_FENCE_LOST"
assert repr(exc_info.value) == "EvoRuntimeError(code='RUN_FENCE_LOST')"
+173
View File
@@ -22,13 +22,19 @@ from EvoScientist.sessions import (
delete_thread,
find_similar_threads,
generate_thread_id,
get_aggregated_storage_stats,
get_db_path,
get_most_recent,
get_thread_messages,
get_thread_metadata,
list_all_session_db_paths,
list_all_thread_ids,
list_threads,
prune_all_stale_threads,
prune_thread_history,
resolve_thread_id_prefix,
thread_exists,
vacuum_db,
)
@@ -2919,5 +2925,172 @@ class TestRestoreWebuiThreadsToGlobalStore(unittest.IsolatedAsyncioTestCase):
assert restore_called, "_restore_webui_threads_to_global_store must be called"
def _uuid6_from_unix(ts_unix: float) -> str:
"""Build a UUIDv6 (time-ordered checkpoint id) from a unix timestamp.
Production checkpoint ids are UUIDv6, so lexicographic order matches
insertion order and the timestamp is recoverable from the id itself.
"""
greg = int((ts_unix + 12219292800) * 10_000_000) & ((1 << 60) - 1)
high48, low12 = greg >> 12, greg & 0xFFF
rand = uuid.uuid4().int & ((1 << 62) - 1)
value = (high48 << 80) | (0x6 << 76) | (low12 << 64) | (0b10 << 62) | rand
return str(uuid.UUID(int=value))
class TestPruneFunctions(unittest.IsolatedAsyncioTestCase):
"""Tests for the prune/vacuum API used by the gateway timer and admin routes."""
async def asyncSetUp(self):
import time
import aiosqlite
self._tmpdir = tempfile.mkdtemp()
self.db_path = os.path.join(self._tmpdir, "prune_test.db")
now = time.time()
async with aiosqlite.connect(self.db_path) as conn:
await conn.execute("""
CREATE TABLE checkpoints (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
parent_checkpoint_id TEXT,
type TEXT,
checkpoint BLOB,
metadata TEXT NOT NULL DEFAULT '{}',
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
)
""")
await conn.execute("""
CREATE TABLE writes (
thread_id TEXT NOT NULL,
checkpoint_ns TEXT NOT NULL DEFAULT '',
checkpoint_id TEXT NOT NULL,
task_id TEXT NOT NULL,
idx INTEGER NOT NULL,
channel TEXT NOT NULL,
type TEXT,
value BLOB,
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id, task_id, idx)
)
""")
await self._insert_thread(conn, "old_thread", 5, now - 10 * 86400)
await self._insert_thread(conn, "new_thread", 3, now - 60)
await self._insert_thread(
conn, "other", 4, now - 10 * 86400, agent="OtherAgent"
)
await conn.commit()
async def asyncTearDown(self):
try:
os.unlink(self.db_path)
os.rmdir(self._tmpdir)
except OSError:
pass
async def _insert_thread(self, conn, tid, count, ts_base, agent=AGENT_NAME):
serde = JsonPlusSerializer()
ctype, cblob = serde.dumps_typed(
{"channel_values": {"messages": [HumanMessage(content=f"seed-{tid}")]}}
)
prev = None
for i in range(count):
cid = _uuid6_from_unix(ts_base + i)
await conn.execute(
"INSERT INTO checkpoints (thread_id, checkpoint_ns, checkpoint_id,"
" parent_checkpoint_id, type, checkpoint, metadata)"
" VALUES (?, '', ?, ?, ?, ?, ?)",
(tid, cid, prev, ctype, cblob, json.dumps({"agent_name": agent})),
)
await conn.execute(
"INSERT INTO writes (thread_id, checkpoint_ns, checkpoint_id,"
" task_id, idx, channel, type, value)"
" VALUES (?, '', ?, 'task', 0, 'ch', 'str', ?)",
(tid, cid, b"x"),
)
prev = cid
async def _count(self, tid, table="checkpoints"):
import aiosqlite
async with aiosqlite.connect(self.db_path) as conn:
async with conn.execute(
f"SELECT COUNT(*) FROM {table} WHERE thread_id = ?", (tid,)
) as cur:
return (await cur.fetchone())[0]
async def test_prune_thread_history(self):
result = await prune_thread_history(
"old_thread", keep_last=2, db_path=self.db_path
)
# keep_last=2 anchors + 1 snapshot-seed ancestor preserved
assert result == {"deleted_checkpoints": 2, "deleted_writes": 2}
assert await self._count("old_thread") == 3
assert await self._count("old_thread", "writes") == 3
async def test_prune_thread_history_other_agent_untouched(self):
result = await prune_thread_history("other", keep_last=1, db_path=self.db_path)
assert result == {"deleted_checkpoints": 0, "deleted_writes": 0}
assert await self._count("other") == 4
async def test_prune_all_stale_threads(self):
result = await prune_all_stale_threads(
max_age_hours=24, keep_last=2, db_path=self.db_path
)
assert result["databases_processed"] == 1
assert result["threads_pruned"] == 1
assert result["total_deleted_checkpoints"] == 2
assert result["total_deleted_writes"] == 2
# fresh thread and foreign-agent thread untouched
assert await self._count("new_thread") == 3
assert await self._count("other") == 4
async def test_prune_all_stale_threads_none_stale(self):
result = await prune_all_stale_threads(
max_age_hours=24 * 365, keep_last=2, db_path=self.db_path
)
assert result["threads_pruned"] == 0
assert result["total_deleted_checkpoints"] == 0
assert await self._count("old_thread") == 5
async def test_list_all_thread_ids(self):
ids = await list_all_thread_ids(db_path=self.db_path)
assert sorted(ids) == ["new_thread", "old_thread"]
async def test_vacuum_db(self):
result = await vacuum_db(db_path=self.db_path)
assert result["size_after_bytes"] > 0
assert result["size_before_bytes"] >= result["size_after_bytes"]
async def test_get_aggregated_storage_stats(self):
with patch(
"EvoScientist.sessions.get_db_path",
return_value=_mock_path(self.db_path),
):
stats = await get_aggregated_storage_stats()
assert stats["thread_count"] == 2
assert stats["checkpoint_count"] == 8
assert stats["thread_depth"]["max"] == 5
assert stats["thread_depth"]["min"] == 3
async def test_list_all_session_db_paths(self):
with patch(
"EvoScientist.sessions.get_db_path",
return_value=_mock_path(self.db_path),
):
paths = list_all_session_db_paths()
assert len(paths) == 1
assert str(paths[0]) == self.db_path
missing = os.path.join(self._tmpdir, "nope.db")
with patch(
"EvoScientist.sessions.get_db_path",
return_value=_mock_path(missing),
):
assert list_all_session_db_paths() == []
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock
import pytest
from langchain_core.messages import ToolMessage
from EvoScientist.middleware.subagent_timeout import SubagentTimeoutMiddleware
def _request(name: str = "task"):
request = MagicMock()
request.tool_call = {"id": "call-1", "name": name, "args": {}}
return request
@pytest.mark.anyio
async def test_subagent_timeout_returns_stable_tool_error():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
async def handler(_request):
await asyncio.sleep(1)
return ToolMessage(content="late", tool_call_id="call-1", name="task")
result = await middleware.awrap_tool_call(_request(), handler)
assert isinstance(result, ToolMessage)
assert result.status == "error"
assert result.name == "task"
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
assert "SUBAGENT_TIMEOUT" in result.content
@pytest.mark.anyio
async def test_subagent_timeout_passes_success_through():
middleware = SubagentTimeoutMiddleware(timeout_seconds=1)
expected = ToolMessage(content="done", tool_call_id="call-1", name="task")
async def handler(_request):
return expected
assert await middleware.awrap_tool_call(_request(), handler) is expected
@pytest.mark.anyio
async def test_subagent_timeout_does_not_bound_other_tools():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
expected = ToolMessage(content="done", tool_call_id="call-1", name="read_file")
async def handler(_request):
await asyncio.sleep(0.02)
return expected
assert await middleware.awrap_tool_call(_request("read_file"), handler) is expected
@pytest.mark.anyio
async def test_subagent_internal_timeout_error_is_not_reclassified():
middleware = SubagentTimeoutMiddleware(timeout_seconds=1)
async def handler(_request):
raise TimeoutError("provider timed out immediately")
with pytest.raises(TimeoutError, match="provider timed out immediately"):
await middleware.awrap_tool_call(_request(), handler)
@pytest.mark.anyio
async def test_parent_cancellation_cancels_subagent_handler():
middleware = SubagentTimeoutMiddleware(timeout_seconds=10)
handler_cancelled = asyncio.Event()
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
finally:
handler_cancelled.set()
return ToolMessage(content="done", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await asyncio.sleep(0)
invocation.cancel()
with pytest.raises(asyncio.CancelledError):
await invocation
assert handler_cancelled.is_set()
@pytest.mark.anyio
async def test_parent_cancellation_wins_over_handler_cleanup_error():
middleware = SubagentTimeoutMiddleware(timeout_seconds=10)
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError as exc:
raise RuntimeError("cleanup failed") from exc
return ToolMessage(content="done", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await asyncio.sleep(0)
invocation.cancel()
with pytest.raises(asyncio.CancelledError):
await invocation
@pytest.mark.anyio
async def test_deadline_wins_over_handler_cleanup_error():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError as exc:
raise RuntimeError("cleanup failed") from exc
return ToolMessage(content="done", tool_call_id="call-1", name="task")
result = await middleware.awrap_tool_call(_request(), handler)
assert isinstance(result, ToolMessage)
assert result.additional_kwargs["error_code"] == "SUBAGENT_TIMEOUT"
@pytest.mark.anyio
async def test_parent_cancellation_during_deadline_cleanup_is_not_swallowed():
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
cleanup_started = asyncio.Event()
release_cleanup = asyncio.Event()
async def handler(_request) -> ToolMessage:
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cleanup_started.set()
await release_cleanup.wait()
raise
return ToolMessage(content="done", tool_call_id="call-1", name="task")
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
await cleanup_started.wait()
invocation.cancel()
release_cleanup.set()
with pytest.raises(asyncio.CancelledError):
await invocation
@pytest.mark.anyio
async def test_parent_cancellation_after_cleanup_before_timeout_return_wins(monkeypatch):
middleware = SubagentTimeoutMiddleware(timeout_seconds=0.01)
async def handler(_request) -> ToolMessage:
await asyncio.Event().wait()
return ToolMessage(content="done", tool_call_id="call-1", name="task")
async def finish_cleanup_then_cancel_parent(_task):
current = asyncio.current_task()
assert current is not None
current.cancel()
monkeypatch.setattr(middleware, "_cancel_task", finish_cleanup_then_cancel_parent)
invocation = asyncio.create_task(
middleware.awrap_tool_call(_request(), handler)
)
with pytest.raises(asyncio.CancelledError):
await invocation
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import pytest
@pytest.mark.anyio
async def test_tavily_search_keeps_indexed_summary_when_source_fetch_fails(monkeypatch):
from EvoScientist.tools import search
class _Client:
def search(self, *_args, **_kwargs):
return {
"results": [
{
"title": "AIR staff profile",
"url": "https://air.cas.cn/example",
"content": "Indexed staff-profile summary.",
}
]
}
recorded: list[tuple[str, str]] = []
async def fetch_failed(_url: str, timeout: float = 10.0) -> str:
return "Error fetching content from https://air.cas.cn/example: DNS failed"
async def record(service: str, action: str) -> None:
recorded.append((service, action))
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
monkeypatch.setattr(search, "fetch_webpage_content", fetch_failed)
monkeypatch.setattr("EvoScientist.runtime_integrations.record_service_usage", record)
result = await search.tavily_search.ainvoke({"query": "高铭 空天院"})
assert "Indexed staff-profile summary." in result
assert "https://air.cas.cn/example" in result
assert "Tavily-indexed summary" in result
assert recorded == [("tavily", "search")]
@pytest.mark.anyio
async def test_tavily_search_bounds_fetched_page_content(monkeypatch):
from EvoScientist.tools import search
class _Client:
def search(self, *_args, **_kwargs):
return {
"results": [
{
"title": f"Result {index}",
"url": f"https://example.com/{index}",
"content": f"Indexed summary {index}",
}
for index in range(3)
]
}
async def huge_page(_url: str, timeout: float = 10.0) -> str:
return "page-content " * 10_000
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
monkeypatch.setattr(search, "fetch_webpage_content", huge_page)
result = await search.tavily_search.ainvoke({"query": "bounded search"})
assert len(result) <= search.MAX_SEARCH_RESULT_CHARS
for index in range(3):
assert f"https://example.com/{index}" in result
assert "[page content truncated]" in result
@pytest.mark.anyio
async def test_tavily_search_preserves_every_result_url_under_total_budget(monkeypatch):
from EvoScientist.tools import search
class _Client:
def search(self, *_args, **_kwargs):
return {
"results": [
{
"title": f"Result {index} " + ("very-long-title " * 800),
"url": f"https://example.com/result-{index}",
"content": f"Indexed summary {index}",
}
for index in range(3)
]
}
async def page(_url: str, timeout: float = 10.0) -> str:
return "page-content " * 1_000
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
monkeypatch.setattr(search, "fetch_webpage_content", page)
result = await search.tavily_search.ainvoke({"query": "preserve urls"})
assert len(result) <= search.MAX_SEARCH_RESULT_CHARS
for index in range(3):
assert f"https://example.com/result-{index}" in result
assert "[search result content truncated to preserve all titles and URLs]" in result
@pytest.mark.anyio
async def test_tavily_search_bounds_maliciously_long_url(monkeypatch):
from EvoScientist.tools import search
long_url = "https://example.com/" + ("a" * 20_000)
class _Client:
def search(self, *_args, **_kwargs):
return {
"results": [
{
"title": "Long URL result",
"url": long_url,
"content": "Indexed summary",
}
]
}
async def page(_url: str, timeout: float = 10.0) -> str:
return "page"
monkeypatch.setattr(search, "_get_tavily_client", lambda: _Client())
monkeypatch.setattr(search, "fetch_webpage_content", page)
result = await search.tavily_search.ainvoke({"query": "long url"})
assert len(result) <= search.MAX_SEARCH_RESULT_CHARS
assert "https://example.com/" in result
assert "[URL truncated]" in result
+178
View File
@@ -6,6 +6,184 @@ from EvoScientist.llm.contracts import EvoRuntimeError
from EvoScientist.web_runtime import _ToolRegistryFenceMiddleware
def test_web_registry_describes_tavily_as_controlled_live_search(monkeypatch):
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
from EvoScientist.web_runtime import web_tool_registry_manifest
manifest, _revision = web_tool_registry_manifest()
tavily = next(item for item in manifest if item["name"] == "tavily_search")
assert "live public web" in tavily["description"].lower()
assert "execute" in tavily["description"].lower()
def test_base_kwargs_install_tavily_on_main_agent(monkeypatch):
import EvoScientist.EvoScientist as agent_module
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
monkeypatch.setattr("EvoScientist.utils.load_subagents", lambda *_args, **_kwargs: [])
kwargs = agent_module._build_base_kwargs(
object(),
[],
cfg=object(),
chat_model=object(),
workspace_dir="/workspace",
)
assert "tavily_search" in {getattr(tool, "name", "") for tool in kwargs["tools"]}
def _stub_agent_build(monkeypatch, agent_module, subagents=None):
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
monkeypatch.setattr(
"EvoScientist.utils.load_subagents",
lambda *_args, **_kwargs: list(subagents or []),
)
@pytest.mark.parametrize(
"reserved_name",
[
"skill_manager",
"execute",
"start_async_task",
"check_async_task",
"update_async_task",
"cancel_async_task",
"list_async_tasks",
],
)
def test_mcp_cannot_override_reserved_tool(monkeypatch, reserved_name):
from types import SimpleNamespace
import EvoScientist.EvoScientist as agent_module
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
monkeypatch.setattr(
agent_module,
"_load_mcp_tools_cached",
lambda **_kwargs: {"main": [SimpleNamespace(name=reserved_name)]},
)
_stub_agent_build(monkeypatch, agent_module)
with pytest.raises(EvoRuntimeError, match="TOOL_REGISTRY_CONFLICT"):
agent_module.load_mcp_and_build_kwargs(
object(),
[],
cfg=object(),
chat_model=object(),
workspace_dir="/workspace",
)
def test_mcp_cannot_duplicate_existing_subagent_tool(monkeypatch):
from types import SimpleNamespace
import EvoScientist.EvoScientist as agent_module
existing = SimpleNamespace(name="shared_search")
injected = SimpleNamespace(name="shared_search")
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
monkeypatch.setattr(
agent_module,
"_load_mcp_tools_cached",
lambda **_kwargs: {"research-agent": [injected]},
)
_stub_agent_build(
monkeypatch,
agent_module,
subagents=[{"name": "research-agent", "tools": [existing]}],
)
with pytest.raises(EvoRuntimeError, match="TOOL_REGISTRY_CONFLICT"):
agent_module.load_mcp_and_build_kwargs(
object(),
[],
cfg=object(),
chat_model=object(),
workspace_dir="/workspace",
)
def test_mcp_cannot_override_builtin_tavily(monkeypatch):
from types import SimpleNamespace
import EvoScientist.EvoScientist as agent_module
monkeypatch.setenv("TAVILY_API_KEY", "test-key")
monkeypatch.setattr(
agent_module,
"_load_mcp_tools_cached",
lambda **_kwargs: {"main": [SimpleNamespace(name="tavily_search")]},
)
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
monkeypatch.setattr("EvoScientist.utils.load_subagents", lambda *_args, **_kwargs: [])
with pytest.raises(EvoRuntimeError, match="TOOL_REGISTRY_CONFLICT"):
agent_module.load_mcp_and_build_kwargs(
object(),
[],
cfg=object(),
chat_model=object(),
workspace_dir="/workspace",
)
def test_same_mcp_tool_can_be_routed_to_multiple_agents(monkeypatch):
from types import SimpleNamespace
import EvoScientist.EvoScientist as agent_module
shared_main = SimpleNamespace(name="shared_search")
shared_research = SimpleNamespace(name="shared_search")
monkeypatch.delenv("TAVILY_API_KEY", raising=False)
monkeypatch.setattr(
agent_module,
"_load_mcp_tools_cached",
lambda **_kwargs: {
"main": [shared_main],
"research-agent": [shared_research],
},
)
monkeypatch.setattr(agent_module, "_ensure_general_purpose_subagent", lambda _subs: None)
monkeypatch.setattr(agent_module, "_inject_subagent_middleware", lambda *_args, **_kwargs: None)
monkeypatch.setattr(agent_module, "_maybe_swap_async_subagents", lambda subs, *_args, **_kwargs: subs)
monkeypatch.setattr(agent_module, "_configured_system_prompt", lambda _cfg: "prompt")
monkeypatch.setattr(
"EvoScientist.utils.load_subagents",
lambda *_args, **_kwargs: [
{"name": "research-agent", "tools": []}
],
)
kwargs = agent_module.load_mcp_and_build_kwargs(
object(),
[],
cfg=object(),
chat_model=object(),
workspace_dir="/workspace",
)
assert shared_main in kwargs["tools"]
research = next(
subagent for subagent in kwargs["subagents"]
if subagent["name"] == "research-agent"
)
assert shared_research in research["tools"]
def test_tool_dispatch_fence_rejects_changed_registry(monkeypatch):
monkeypatch.setattr(
"EvoScientist.web_runtime.web_tool_registry_manifest",
+374 -16
View File
@@ -2,10 +2,14 @@ from __future__ import annotations
import base64
import os
import sqlite3
import zipfile
from io import BytesIO
from pathlib import Path
import pytest
from deepagents.backends.protocol import ExecuteResponse
from PIL import Image
from EvoScientist.native_sandbox import (
NativeSandboxExecutor,
@@ -27,6 +31,19 @@ from EvoScientist.workspace_scope import (
)
def test_extracted_documents_route_through_deepagents_as_text():
import deepagents.middleware.filesystem as filesystem_middleware
from EvoScientist.llm.patches import _patch_deepagents_extracted_document_text
_patch_deepagents_extracted_document_text()
get_file_type = vars(filesystem_middleware)["_get_file_type"]
assert get_file_type("/workspace/report.pptx") == "text"
assert get_file_type("/workspace/report.pdf") == "text"
assert get_file_type("/workspace/image.png") == "image"
def test_normalize_workspace_path_is_strict():
assert normalize_workspace_path("/workspace") == ()
assert normalize_workspace_path("/workspace/reports/a.txt") == (
@@ -82,25 +99,366 @@ def test_root_lists_workspace_namespace(tmp_path: Path):
]
def test_docx_and_unknown_binary_read_with_base64_contract(tmp_path: Path):
def test_docx_read_extracts_text_instead_of_returning_base64(tmp_path: Path):
backend = ScopedFilesystemBackend(tmp_path)
docx = b"PK\x03\x04\x00word/document.xml"
unknown = b"custom\x00binary"
backend.upload_files(
[
("/workspace/input.docx", docx),
("/workspace/payload.custom", unknown),
]
docx = tmp_path / "input.docx"
with zipfile.ZipFile(docx, "w") as archive:
archive.writestr(
"word/document.xml",
"""<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>有界文档内容</w:t></w:r></w:p></w:body>
</w:document>""",
)
result = backend.read("/workspace/input.docx")
assert result.error is None
assert result.file_data is not None
assert result.file_data["encoding"] == "utf-8"
assert "有界文档内容" in result.file_data["content"]
assert "base64" not in result.file_data["content"]
@pytest.mark.parametrize(
("filename", "kind", "expected"),
[
("archive.zip", "archive", "list"),
("results.sqlite", "database", "mode=ro"),
("program.exe", "executable", "must not be executed"),
("payload.custom", "binary", "unsupported"),
],
)
def test_non_document_binary_returns_bounded_processing_guidance(
tmp_path: Path, filename: str, kind: str, expected: str
):
if filename.endswith(".zip"):
with zipfile.ZipFile(tmp_path / filename, "w") as archive:
archive.writestr("notes.txt", "hello")
elif filename.endswith(".sqlite"):
connection = sqlite3.connect(tmp_path / filename)
connection.execute("CREATE TABLE results(id INTEGER PRIMARY KEY, value TEXT)")
connection.commit()
connection.close()
elif filename.endswith(".exe"):
(tmp_path / filename).write_bytes(b"MZ\x00binary payload")
else:
(tmp_path / filename).write_bytes(b"custom\x00binary payload")
result = ScopedFilesystemBackend(tmp_path).read(f"/workspace/{filename}")
assert result.file_data is None
assert result.error is not None
assert "BINARY_PROCESSING_REQUIRED" in result.error or "UNSUPPORTED_BINARY_FILE" in result.error
assert f'"kind": "{kind}"' in result.error
assert expected.lower() in result.error.lower()
assert len(result.error) < 4000
def test_image_read_keeps_base64_media_contract(tmp_path: Path):
buffer = BytesIO()
Image.new("RGB", (32, 24), (1, 2, 3)).save(buffer, "PNG")
raw = buffer.getvalue()
(tmp_path / "image.png").write_bytes(raw)
result = ScopedFilesystemBackend(tmp_path).read("/workspace/image.png")
assert result.error is None
assert result.file_data is not None
assert result.file_data["encoding"] == "base64"
def test_large_image_is_downsampled_before_base64_delivery(tmp_path: Path):
Image.new("RGB", (3000, 1200), (1, 2, 3)).save(tmp_path / "large.png", "PNG")
result = ScopedFilesystemBackend(tmp_path).read("/workspace/large.png")
assert result.error is None
assert result.file_data is not None
decoded = base64.standard_b64decode(result.file_data["content"])
image = Image.open(BytesIO(decoded))
image.load()
assert image.size == (2048, 819)
assert image.format == "JPEG"
def test_corrupt_image_returns_error_instead_of_base64(tmp_path: Path):
(tmp_path / "broken.png").write_bytes(b"\x89PNG\r\nnot-decodable")
result = ScopedFilesystemBackend(tmp_path).read("/workspace/broken.png")
assert result.file_data is None
assert result.error is not None
assert "IMAGE_PROCESSING_FAILED" in result.error
def test_image_pixel_budget_is_enforced_before_model_delivery(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
import EvoScientist.document_extract as document_extract
Image.new("RGB", (100, 100), (1, 2, 3)).save(tmp_path / "pixels.png", "PNG")
monkeypatch.setattr(document_extract, "MAX_IMAGE_PIXELS", 9_999)
result = ScopedFilesystemBackend(tmp_path).read("/workspace/pixels.png")
assert result.file_data is None
assert result.error is not None
assert "IMAGE_PIXEL_BUDGET_EXCEEDED" in result.error
def test_multiframe_image_is_reduced_to_first_frame(tmp_path: Path):
frames = [Image.new("RGB", (20, 10), color) for color in ((255, 0, 0), (0, 255, 0))]
frames[0].save(
tmp_path / "animated.gif",
format="GIF",
save_all=True,
append_images=frames[1:],
duration=100,
loop=0,
)
assert backend.read("/workspace/input.docx").file_data == {
"content": base64.standard_b64encode(docx).decode("ascii"),
"encoding": "base64",
}
assert backend.read("/workspace/payload.custom").file_data == {
"content": base64.standard_b64encode(unknown).decode("ascii"),
"encoding": "base64",
}
result = ScopedFilesystemBackend(tmp_path).read("/workspace/animated.gif")
assert result.error is None
assert result.file_data is not None
decoded = base64.standard_b64decode(result.file_data["content"])
image = Image.open(BytesIO(decoded))
image.load()
assert getattr(image, "n_frames", 1) == 1
def test_pptx_read_extracts_slide_text_without_base64(tmp_path: Path):
with zipfile.ZipFile(tmp_path / "deck.pptx", "w") as archive:
archive.writestr(
"ppt/slides/slide1.xml",
"""<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><a:t>总体技术架构</a:t><a:t>核心能力说明</a:t></p:cSld>
</p:sld>""",
)
result = ScopedFilesystemBackend(tmp_path).read("/workspace/deck.pptx")
assert result.error is None
assert result.file_data is not None
assert result.file_data["encoding"] == "utf-8"
assert "## Slide 1" in result.file_data["content"]
assert "总体技术架构" in result.file_data["content"]
def test_document_output_is_bounded_and_returns_continuation_hint(tmp_path: Path):
long_text = "\n".join(f"第{i:05d}行-" + "x" * 80 for i in range(2000))
with zipfile.ZipFile(tmp_path / "long.docx", "w") as archive:
paragraphs = "".join(
f"<w:p><w:r><w:t>{line}</w:t></w:r></w:p>"
for line in long_text.splitlines()
)
archive.writestr(
"word/document.xml",
f"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>{paragraphs}</w:body></w:document>""",
)
result = ScopedFilesystemBackend(tmp_path).read(
"/workspace/long.docx", offset=0, limit=2000
)
assert result.error is None
assert result.file_data is not None
content = result.file_data["content"]
assert len(content) < 51_000
assert "DOCUMENT_OUTPUT_TRUNCATED" in content
assert "use offset=" in content
def test_corrupt_document_does_not_fall_back_to_base64(tmp_path: Path):
(tmp_path / "broken.pptx").write_bytes(b"PK\x03\x04not-a-real-presentation")
result = ScopedFilesystemBackend(tmp_path).read("/workspace/broken.pptx")
assert result.file_data is None
assert result.error is not None
assert "DOCUMENT_EXTRACTION_FAILED" in result.error
assert "base64" not in result.error.lower()
def test_ooxml_member_expansion_budget_blocks_compression_bomb(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
import EvoScientist.document_extract as document_extract
with zipfile.ZipFile(
tmp_path / "bomb.docx", "w", compression=zipfile.ZIP_DEFLATED
) as archive:
archive.writestr(
"word/document.xml",
"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>expanded content</w:t></w:r></w:p></w:body>
</w:document>""",
)
monkeypatch.setattr(document_extract, "MAX_OOXML_MEMBER_BYTES", 16)
result = ScopedFilesystemBackend(tmp_path).read("/workspace/bomb.docx")
assert result.file_data is None
assert result.error is not None
assert "DOCUMENT_RESOURCE_LIMIT" in result.error
@pytest.mark.parametrize("member", ["../word/document.xml", "/word/document.xml"])
def test_ooxml_rejects_unsafe_member_paths(tmp_path: Path, member: str):
with zipfile.ZipFile(tmp_path / "unsafe.docx", "w") as archive:
archive.writestr(member, "content")
archive.writestr(
"word/document.xml",
"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>safe</w:t></w:r></w:p></w:body></w:document>""",
)
result = ScopedFilesystemBackend(tmp_path).read("/workspace/unsafe.docx")
assert result.file_data is None
assert result.error is not None
assert "DOCUMENT_RESOURCE_LIMIT" in result.error
def test_ooxml_rejects_duplicate_member_names(tmp_path: Path):
def write_duplicate_document(path: Path) -> None:
with zipfile.ZipFile(path, "w") as archive:
for text in ("first", "second"):
archive.writestr(
"word/document.xml",
f"""<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:body></w:document>""",
)
with pytest.warns(UserWarning, match="Duplicate name"):
write_duplicate_document(tmp_path / "duplicate.docx")
result = ScopedFilesystemBackend(tmp_path).read("/workspace/duplicate.docx")
assert result.file_data is None
assert result.error is not None
assert "DOCUMENT_RESOURCE_LIMIT" in result.error
def test_oversized_document_is_rejected_before_opening_content(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
from EvoScientist.document_extract import MAX_DOCUMENT_BYTES
path = tmp_path / "oversized.pdf"
path.write_bytes(b"%PDF")
original_entry = RootedWorkspace.entry
def oversized_entry(self, virtual_path):
entry = original_entry(self, virtual_path)
return type(entry)(entry.virtual_path, entry.is_dir, MAX_DOCUMENT_BYTES + 1, entry.modified_at)
monkeypatch.setattr(RootedWorkspace, "entry", oversized_entry)
result = ScopedFilesystemBackend(tmp_path).read("/workspace/oversized.pdf")
assert result.file_data is None
assert result.error is not None
assert "DOCUMENT_TOO_LARGE" in result.error
def test_external_document_converter_timeout_is_structured(
monkeypatch: pytest.MonkeyPatch,
):
import subprocess
import EvoScientist.document_extract as document_extract
def timeout(*args, **kwargs):
raise subprocess.TimeoutExpired(cmd="anydoc", timeout=60)
monkeypatch.setattr(document_extract.subprocess, "run", timeout)
with pytest.raises(
document_extract.DocumentExtractionError,
match="DOCUMENT_CONVERSION_TIMEOUT",
):
document_extract.extract_document_bytes(b"%PDF-minimal", "sample.pdf")
@pytest.mark.parametrize("filename", ["report.pptx", "archive.zip", "results.sqlite"])
def test_text_write_cannot_create_or_corrupt_binary_container(
tmp_path: Path, filename: str
):
backend = ScopedFilesystemBackend(tmp_path)
created = backend.write(f"/workspace/{filename}", "extracted text")
assert created.error is not None
assert "binary container" in created.error.lower()
assert not (tmp_path / filename).exists()
def test_text_edit_cannot_modify_existing_binary_container(tmp_path: Path):
source = b"PK\x03\x04original-container"
(tmp_path / "report.pptx").write_bytes(source)
backend = ScopedFilesystemBackend(tmp_path)
edited = backend.edit(
"/workspace/report.pptx", "original", "replacement"
)
assert edited.error is not None
assert "binary container" in edited.error.lower()
assert (tmp_path / "report.pptx").read_bytes() == source
def test_uploaded_text_is_read_only_to_file_tools(tmp_path: Path):
uploads = tmp_path / "uploads"
uploads.mkdir()
source = uploads / "notes.txt"
source.write_text("original", encoding="utf-8")
backend = ScopedFilesystemBackend(tmp_path)
written = backend.write("/workspace/uploads/new.txt", "new")
edited = backend.edit("/workspace/uploads/notes.txt", "original", "changed")
assert written.error is not None
assert "uploads" in written.error.lower()
assert edited.error is not None
assert "uploads" in edited.error.lower()
assert not (uploads / "new.txt").exists()
assert source.read_text(encoding="utf-8") == "original"
def test_utf8_sample_boundary_cut_is_not_binary(tmp_path: Path):
# Regression (2026-08-22): the 8192-byte UTF-8 probe can split a multi-byte
# CJK character at the sample boundary (req_v13.md cut at 8190/8191 split a
# 3-byte char). That raised UnicodeDecodeError -> misclassified as binary
# -> read_file returned a base64 file media block -> providers without file
# input replaced it with a placeholder -> the model retried forever.
backend = ScopedFilesystemBackend(tmp_path)
# 8190 ASCII bytes + one 3-byte CJK char, so the sample cuts mid-character.
content = ("a" * 8190 + "\u6e56" + "more text").encode("utf-8")
assert len(content) > 8192
assert content[8190:8193] == "\u6e56".encode("utf-8")
backend.upload_files([("/workspace/cjk.md", content)])
result = backend.read("/workspace/cjk.md")
assert result.file_data is not None
assert result.file_data["encoding"] == "utf-8"
assert result.file_data["content"].startswith("a" * 10)
def test_mid_sample_invalid_bytes_still_binary():
from EvoScientist.workspace_files import _is_binary_file
# Invalid bytes well inside the sample are genuine garbage, not a cut.
assert _is_binary_file("/workspace/bad.raw", b"ok\xffi\xffd\xefmore")
# A boundary cut (error in the last 4 bytes that decodes clean when the
# dangling suffix is dropped) is text.
cut = ("a" * 8190 + "\u6e56").encode("utf-8")[:8192]
assert not _is_binary_file("/workspace/cut.md", cut)
# Same shape but the prefix itself is invalid -> stays binary.
assert _is_binary_file("/workspace/bad.md", b"\xff" * 8192)
def test_symlink_targets_and_parents_are_rejected(tmp_path: Path):