fix: stop masking runtime failures

This commit is contained in:
chengyongru
2026-07-21 11:44:52 +08:00
committed by chengyongru
parent afc65c086e
commit dfc3919b52
22 changed files with 446 additions and 300 deletions
+47
View File
@@ -260,6 +260,42 @@ class TestConsolidatorArchiveErrorHandling:
assert len(entries) == 1
assert "[RAW]" not in entries[0]["content"]
async def test_archive_propagates_history_write_failure(
self, consolidator, mock_provider, runtime
):
mock_provider.chat_with_retry.return_value = MagicMock(
content="Summary.",
finish_reason="stop",
)
consolidator.store.append_history = MagicMock(side_effect=OSError("disk full"))
consolidator.store.raw_archive = MagicMock()
with pytest.raises(OSError, match="disk full"):
await consolidator.archive(
[{"role": "user", "content": "important"}],
runtime=runtime,
)
consolidator.store.raw_archive.assert_not_called()
async def test_archive_propagates_template_failure_without_raw_archive(
self, consolidator, mock_provider, runtime, monkeypatch
):
consolidator.store.raw_archive = MagicMock()
monkeypatch.setattr(
"nanobot.agent.memory.render_template",
MagicMock(side_effect=RuntimeError("template failed")),
)
with pytest.raises(RuntimeError, match="template failed"):
await consolidator.archive(
[{"role": "user", "content": "important"}],
runtime=runtime,
)
mock_provider.chat_with_retry.assert_not_awaited()
consolidator.store.raw_archive.assert_not_called()
class TestConsolidatorTokenBudget:
async def test_prompt_below_threshold_does_not_consolidate(
@@ -276,6 +312,17 @@ class TestConsolidatorTokenBudget:
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
consolidator.archive.assert_not_called()
async def test_token_estimation_failure_propagates(self, consolidator, runtime):
session = Session(key="test:estimate-failure")
session.add_message("user", "hello")
consolidator.sessions._session_cache[session.key] = session
consolidator.estimate_session_prompt_tokens = MagicMock(
side_effect=RuntimeError("counter failed")
)
with pytest.raises(RuntimeError, match="counter failed"):
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
async def test_estimate_uses_full_unconsolidated_tail(self, consolidator, runtime):
"""Consolidation pressure must see messages hidden by the replay window."""
session = Session(key="test:full-tail")
+12 -1
View File
@@ -1,9 +1,10 @@
"""Tests for GitStore — git-backed version control for memory files."""
from unittest.mock import patch
import pytest
from nanobot.utils.gitstore import CommitInfo, GitStore
from nanobot.utils.gitstore import CommitInfo, GitStore, GitStoreError
TRACKED = ["SOUL.md", "USER.md", "memory/MEMORY.md"]
@@ -49,6 +50,11 @@ class TestInit:
assert len(commits) == 1
assert "init" in commits[0].message
def test_init_failure_is_explicit(self, git):
with patch("dulwich.porcelain.init", side_effect=OSError("cannot initialize")):
with pytest.raises(GitStoreError, match="init failed"):
git.init()
class TestBuildGitignore:
def test_subdirectory_dirs(self, git):
@@ -97,6 +103,11 @@ class TestAutoCommit:
git_ready.auto_commit("nothing 2")
assert len(git_ready.log()) == 1 # only init commit
def test_status_failure_is_explicit(self, git_ready):
with patch("dulwich.porcelain.status", side_effect=OSError("broken index")):
with pytest.raises(GitStoreError, match="auto-commit failed"):
git_ready.auto_commit("update")
class TestLog:
def test_empty_when_not_initialized(self, git):
+11 -17
View File
@@ -58,17 +58,11 @@ def _make_loop(tmp_path):
return loop
async def test_runner_uses_raw_messages_when_context_governance_fails():
async def test_runner_propagates_context_governance_failure():
from nanobot.agent.runner import AgentRunner
provider = MagicMock()
captured_messages: list[dict] = []
async def chat_with_retry(*, messages, **kwargs):
captured_messages[:] = messages
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_with_retry = chat_with_retry
provider.chat_with_retry = AsyncMock()
tools = MagicMock()
tools.get_definitions.return_value = []
initial_messages = [
@@ -80,16 +74,16 @@ async def test_runner_uses_raw_messages_when_context_governance_fails():
runner.context_governor.prepare_for_model = MagicMock( # type: ignore[method-assign]
side_effect=RuntimeError("boom")
)
result = await runner.run(make_run_spec(provider,
initial_messages=initial_messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
with pytest.raises(RuntimeError, match="boom"):
await runner.run(make_run_spec(provider,
initial_messages=initial_messages,
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
))
assert result.final_content == "done"
assert captured_messages == initial_messages
provider.chat_with_retry.assert_not_awaited()
def test_snip_history_drops_orphaned_tool_results_from_trimmed_slice(monkeypatch):
+24
View File
@@ -149,6 +149,30 @@ def _tool_message(result, tool_call_id: str) -> dict:
][0]
@pytest.mark.asyncio
async def test_runner_propagates_tool_preparation_failure():
tools = MagicMock()
tools.prepare_call.side_effect = RuntimeError("tool preparation failed")
tools.execute = AsyncMock()
with pytest.raises(RuntimeError, match="tool preparation failed"):
await AgentRunner()._run_tool(
make_run_spec(
MagicMock(),
initial_messages=[],
tools=tools,
model="test-model",
max_iterations=1,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
),
ToolCallRequest(id="call-1", name="demo", arguments={}),
{},
{},
)
tools.execute.assert_not_awaited()
@pytest.mark.asyncio
async def test_runner_batches_read_only_tools_before_exclusive_work():
tools = ToolRegistry()
+2
View File
@@ -349,6 +349,7 @@ class TestConsolidationUnaffectedByUnifiedSession:
session = Session(key="unified:default")
session.messages = []
sessions.get_or_create.return_value = session
await consolidator.maybe_consolidate_by_tokens(session, runtime=runtime)
@@ -378,6 +379,7 @@ class TestConsolidationUnaffectedByUnifiedSession:
session = Session(key=key)
session.messages = [] # empty → exits immediately for both keys
sessions.get_or_create.return_value = session
consolidator.archive = AsyncMock()
await consolidator.maybe_consolidate_by_tokens(
+30
View File
@@ -2508,6 +2508,36 @@ def test_optional_dependency_groups_falls_back_to_package_metadata(monkeypatch):
)
def test_load_pyproject_propagates_malformed_toml(tmp_path):
from nanobot import optional_features
path = tmp_path / "pyproject.toml"
path.write_text("[project\nname = 'nanobot'", encoding="utf-8")
with pytest.raises(tomllib.TOMLDecodeError):
optional_features.load_pyproject(path)
def test_optional_dependency_metadata_propagates_malformed_requirement(monkeypatch):
from packaging.requirements import InvalidRequirement
from nanobot import optional_features
class _Metadata:
def get_all(self, key: str):
assert key == "Provides-Extra"
return ["bedrock"]
monkeypatch.setattr("importlib.metadata.metadata", lambda _name: _Metadata())
monkeypatch.setattr(
"importlib.metadata.requires",
lambda _name: ["not a valid requirement ???"],
)
with pytest.raises(InvalidRequirement):
optional_features.optional_dependency_groups_from_metadata()
def test_install_args_for_extra_resolves_metadata_markers_for_current_platform():
from nanobot import optional_features
+5
View File
@@ -16,6 +16,11 @@ def test_resolve_preset_returns_defaults_when_no_preset() -> None:
assert resolved.reasoning_effort == config.agents.defaults.reasoning_effort
def test_agent_timezone_rejects_unknown_iana_name() -> None:
with pytest.raises(ValueError, match="unknown timezone"):
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}})
def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({
"providers": {
+24
View File
@@ -170,3 +170,27 @@ class TestFlushAll:
assert len(history) == 2
assert history[0]["content"] == "remember this"
assert history[1]["content"] == "noted"
class TestLoadErrors:
@pytest.mark.parametrize(
"operation",
("get_or_create", "read_session_file", "read_session_metadata", "list_sessions"),
)
def test_permission_error_is_not_treated_as_corrupt_data(
self,
sessions_dir: Path,
operation: str,
) -> None:
writer = SessionManager(workspace=sessions_dir)
session = writer.get_or_create("test:permission")
session.add_message("user", "must not disappear")
writer.save(session)
reader = SessionManager(workspace=sessions_dir)
with patch("builtins.open", side_effect=PermissionError("access denied")):
with pytest.raises(PermissionError, match="access denied"):
if operation == "list_sessions":
reader.list_sessions()
else:
getattr(reader, operation)("test:permission")
+2 -60
View File
@@ -421,65 +421,7 @@ async def test_multimodal_remote_image_url_returns_400(aiohttp_client, mock_agen
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_empty_response_retry_then_success(aiohttp_client) -> None:
call_count = 0
async def sometimes_empty(content, session_key="", channel="", chat_id="", **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return ""
return "recovered response"
agent = MagicMock()
agent.process_direct = sometimes_empty
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY)
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
headers=AUTH_HEADERS,
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert resp.status == 200
body = await resp.json()
assert body["choices"][0]["message"]["content"] == "recovered response"
assert call_count == 2
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_empty_response_retry_does_not_duplicate_user_turn(aiohttp_client) -> None:
persist_flags = []
async def record(content, session_key="", channel="", chat_id="", **kwargs):
persist_flags.append(kwargs.get("persist_user_message", True))
return "" if len(persist_flags) == 1 else "recovered response"
agent = MagicMock()
agent.process_direct = record
agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m", api_key=API_KEY)
client = await aiohttp_client(app)
resp = await client.post(
"/v1/chat/completions",
headers=AUTH_HEADERS,
json={"messages": [{"role": "user", "content": "hello"}]},
)
assert resp.status == 200
# first call persists the user turn; the retry must not persist it again
assert persist_flags == [True, False]
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
@pytest.mark.asyncio
async def test_empty_response_falls_back(aiohttp_client) -> None:
async def test_empty_response_falls_back_without_retry(aiohttp_client) -> None:
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
call_count = 0
@@ -505,7 +447,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
assert resp.status == 200
body = await resp.json()
assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE
assert call_count == 2
assert call_count == 1
@pytest.mark.asyncio
+8 -6
View File
@@ -7,7 +7,7 @@ from unittest.mock import patch
import pytest
from nanobot.utils.gitstore import GitStore
from nanobot.utils.gitstore import GitStore, GitStoreError
@pytest.fixture
@@ -63,11 +63,13 @@ class TestLineAges:
assert len(ages) == 2
assert all(a.age_days == 30 for a in ages)
def test_annotate_failure_returns_empty(self, tmp_path):
"""If annotate fails, line_ages should return [] gracefully."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
# Don't init — annotate will fail
assert git.line_ages("MEMORY.md") == []
def test_annotate_failure_is_explicit(self, git, tmp_path):
(tmp_path / "MEMORY.md").write_text("important\n", encoding="utf-8")
git.auto_commit("initial")
with patch("dulwich.porcelain.annotate", side_effect=OSError("broken repo")):
with pytest.raises(GitStoreError, match="annotation failed"):
git.line_ages("MEMORY.md")
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
"""Only modified lines should reflect the new commit's timestamp."""
+13 -1
View File
@@ -1,9 +1,16 @@
from pathlib import Path
from zoneinfo import ZoneInfoNotFoundError
import pytest
import tiktoken
from nanobot.utils import helpers
from nanobot.utils.helpers import _write_text_atomic, split_message, truncate_text_to_tokens
from nanobot.utils.helpers import (
_write_text_atomic,
current_time_str,
split_message,
truncate_text_to_tokens,
)
def test_split_message_no_code_blocks_unchanged():
@@ -43,6 +50,11 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text():
assert truncate_text_to_tokens(text, 0) == text
def test_current_time_str_rejects_unknown_timezone():
with pytest.raises(ZoneInfoNotFoundError):
current_time_str("Not/AZone")
def test_write_text_atomic_fsyncs_file_and_parent_directory(
tmp_path: Path, monkeypatch
) -> None:
+57 -1
View File
@@ -1,7 +1,12 @@
import json
from nanobot.utils import helpers
from nanobot.utils.helpers import estimate_prompt_tokens, estimate_prompt_tokens_chain
from nanobot.utils.helpers import (
estimate_message_tokens,
estimate_prompt_tokens,
estimate_prompt_tokens_chain,
truncate_text_to_tokens,
)
class _NoCounterProvider:
@@ -35,6 +40,57 @@ def test_estimate_prompt_tokens_chain_falls_back_when_provider_counter_fails() -
assert source == "tiktoken"
def test_estimate_prompt_tokens_uses_conservative_fallback_when_tiktoken_fails(
monkeypatch,
) -> None:
monkeypatch.setattr(
helpers,
"_get_token_encoding",
lambda: (_ for _ in ()).throw(RuntimeError("encoding unavailable")),
)
content = "" * 1_000
messages = [{"role": "user", "content": content}]
tokens = estimate_prompt_tokens(messages)
chain_tokens, source = estimate_prompt_tokens_chain(
_NoCounterProvider(),
"test-model",
messages,
)
actual_tokens = len(helpers.tiktoken.get_encoding("cl100k_base").encode(content)) + 4
assert tokens == len(content.encode("utf-8")) + 4
assert tokens >= actual_tokens
assert chain_tokens == tokens
assert source == "heuristic"
def test_estimate_message_tokens_uses_utf8_byte_fallback(monkeypatch) -> None:
monkeypatch.setattr(
helpers,
"_get_token_encoding",
lambda: (_ for _ in ()).throw(RuntimeError("encoding unavailable")),
)
content = "🙂你" * 100
assert estimate_message_tokens({"role": "user", "content": content}) == (
len(content.encode("utf-8")) + 4
)
def test_truncate_text_to_tokens_uses_utf8_byte_budget_fallback(monkeypatch) -> None:
monkeypatch.setattr(
helpers,
"_get_token_encoding",
lambda: (_ for _ in ()).throw(RuntimeError("encoding unavailable")),
)
result = truncate_text_to_tokens("🙂你" * 100, 40)
assert result.endswith("\n... (truncated)")
assert len(result.encode("utf-8")) <= 40
def test_estimate_prompt_tokens_caches_tools_encoding(monkeypatch) -> None:
helpers._get_token_encoding.cache_clear()
helpers._TOOLS_TOKEN_CACHE.clear()
+15
View File
@@ -34,6 +34,21 @@ DYNAMIC_PROVIDER_NAME = "my-company-api"
DYNAMIC_PROVIDER_API_BASE = "https://example.test/v1"
def test_settings_payload_propagates_preset_resolution_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
config = Config()
monkeypatch.setattr("nanobot.webui.settings_api.load_config", lambda: config)
monkeypatch.setattr(
Config,
"resolve_preset",
lambda _self: (_ for _ in ()).throw(RuntimeError("invalid preset")),
)
with pytest.raises(RuntimeError, match="invalid preset"):
settings_payload()
def test_docs_version_uses_released_versions_and_falls_back_for_dev() -> None:
assert _docs_version("0.2.3") == "0.2.3"
assert _docs_version("0.2.3.post1") == "0.2.3.post1"