Merge remote-tracking branch 'origin/main' into nanobot-webui

This commit is contained in:
Xubin Ren
2026-04-19 12:36:52 +00:00
30 changed files with 2672 additions and 2286 deletions
+59 -3
View File
@@ -102,7 +102,7 @@ async def test_consolidation_loops_until_target_met(tmp_path, monkeypatch) -> No
loop.sessions.save(session)
call_count = [0]
def mock_estimate(_session):
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
@@ -139,7 +139,7 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
call_count = [0]
def mock_estimate(_session):
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
@@ -156,6 +156,61 @@ async def test_consolidation_continues_below_trigger_until_half_target(tmp_path,
assert session.last_consolidated == 6
@pytest.mark.asyncio
async def test_consolidation_persists_summary_for_next_prepare_session(tmp_path, monkeypatch) -> None:
loop = _make_loop(tmp_path, estimated_tokens=0, context_window_tokens=200)
loop.consolidator.archive = AsyncMock(return_value="User discussed project status.") # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
{"role": "user", "content": "u1", "timestamp": "2026-01-01T00:00:00"},
{"role": "assistant", "content": "a1", "timestamp": "2026-01-01T00:00:01"},
{"role": "user", "content": "u2", "timestamp": "2026-01-01T00:00:02"},
]
loop.sessions.save(session)
call_count = [0]
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
if call_count[0] == 1:
return (500, "test")
return (80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 150)
await loop.consolidator.maybe_consolidate_by_tokens(session)
reloaded = loop.sessions.get_or_create("cli:test")
meta = reloaded.metadata.get("_last_summary")
assert meta is not None
assert meta["text"] == "User discussed project status."
reloaded, pending = loop.auto_compact.prepare_session(reloaded, "cli:test")
assert pending is not None
assert "User discussed project status." in pending
assert "_last_summary" not in reloaded.metadata
@pytest.mark.asyncio
async def test_preflight_consolidation_receives_pending_summary(tmp_path) -> None:
loop = _make_loop(tmp_path, estimated_tokens=100, context_window_tokens=200)
session = loop.sessions.get_or_create("cli:test")
loop.auto_compact.prepare_session = MagicMock(
return_value=(session, "Previous conversation summary: earlier context")
) # type: ignore[method-assign]
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=None) # type: ignore[method-assign]
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
await loop.process_direct("hello", session_key="cli:test")
loop.consolidator.maybe_consolidate_by_tokens.assert_awaited_once_with(
session,
session_summary="Previous conversation summary: earlier context",
)
@pytest.mark.asyncio
async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) -> None:
"""Verify preflight consolidation runs before the LLM call in process_direct."""
@@ -173,6 +228,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
return LLMResponse(content="ok", tool_calls=[])
loop.provider.chat_with_retry = track_llm
loop.provider.chat_stream_with_retry = track_llm
loop._schedule_background = lambda coro: coro.close() # type: ignore[method-assign]
session = loop.sessions.get_or_create("cli:test")
session.messages = [
@@ -184,7 +240,7 @@ async def test_preflight_consolidation_before_llm_call(tmp_path, monkeypatch) ->
monkeypatch.setattr(memory_module, "estimate_message_tokens", lambda _m: 500)
call_count = [0]
def mock_estimate(_session):
def mock_estimate(_session, *, session_summary=None):
call_count[0] += 1
return (1000 if call_count[0] <= 1 else 80, "test")
loop.consolidator.estimate_session_prompt_tokens = mock_estimate # type: ignore[method-assign]
+4 -1
View File
@@ -395,7 +395,10 @@ class TestConsolidationUnaffectedByUnifiedSession:
await consolidator.maybe_consolidate_by_tokens(session)
# estimate was called (consolidation was attempted)
consolidator.estimate_session_prompt_tokens.assert_called_once_with(session)
consolidator.estimate_session_prompt_tokens.assert_called_once_with(
session,
session_summary=None,
)
# but archive was not called (no valid boundary)
consolidator.archive.assert_not_called()
+28 -1
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
import os
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -31,6 +32,15 @@ def _make_loop():
return loop, bus
async def _wait_until(predicate, *, timeout: float = 0.2, interval: float = 0.01) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(interval)
assert predicate()
class TestRestartCommand:
@pytest.mark.asyncio
@@ -47,7 +57,23 @@ class TestRestartCommand:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart")
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/restart", loop=loop)
async def _fast_sleep(_delay: float) -> None:
return None
scheduled: list[asyncio.Task] = []
def _capture_task(coro):
task = asyncio.create_task(coro)
scheduled.append(task)
return task
fake_asyncio = SimpleNamespace(
sleep=_fast_sleep,
create_task=_capture_task,
)
with patch.dict(os.environ, {}, clear=False), \
patch("nanobot.command.builtin.asyncio", new=fake_asyncio), \
patch("nanobot.command.builtin.os.execv") as mock_execv:
out = await cmd_restart(ctx)
assert "Restarting" in out.content
@@ -55,7 +81,8 @@ class TestRestartCommand:
assert os.environ.get(RESTART_NOTIFY_CHAT_ID_ENV) == "direct"
assert os.environ.get(RESTART_STARTED_AT_ENV)
await asyncio.sleep(1.5)
assert scheduled
await scheduled[0]
mock_execv.assert_called_once()
@pytest.mark.asyncio
+19 -10
View File
@@ -8,6 +8,15 @@ from nanobot.cron.service import CronService
from nanobot.cron.types import CronJob, CronPayload, CronSchedule
async def _wait_until(predicate, *, timeout: float = 1.0, interval: float = 0.01) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(interval)
assert predicate()
def test_add_job_rejects_unknown_timezone(tmp_path) -> None:
service = CronService(tmp_path / "cron" / "jobs.json")
@@ -201,18 +210,18 @@ async def test_start_server_not_jobs(tmp_path):
async def on_job(job):
called.append(job.name)
service = CronService(store_path, on_job=on_job, max_sleep_ms=1000)
service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
await service.start()
assert len(service.list_jobs()) == 0
service2 = CronService(tmp_path / "cron" / "jobs.json")
service2.add_job(
name="hist",
schedule=CronSchedule(kind="every", every_ms=500),
schedule=CronSchedule(kind="every", every_ms=100),
message="hello",
)
assert len(service.list_jobs()) == 1
await asyncio.sleep(2)
await _wait_until(lambda: bool(called), timeout=0.8)
assert len(called) != 0
service.stop()
@@ -248,10 +257,10 @@ async def test_running_service_picks_up_external_add(tmp_path):
async def on_job(job):
called.append(job.name)
service = CronService(store_path, on_job=on_job)
service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
service.add_job(
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=150),
schedule=CronSchedule(kind="every", every_ms=100),
message="tick",
)
await service.start()
@@ -261,11 +270,11 @@ async def test_running_service_picks_up_external_add(tmp_path):
external = CronService(store_path)
external.add_job(
name="external",
schedule=CronSchedule(kind="every", every_ms=150),
schedule=CronSchedule(kind="every", every_ms=100),
message="ping",
)
await asyncio.sleep(2)
await _wait_until(lambda: "external" in called, timeout=0.8)
assert "external" in called
finally:
service.stop()
@@ -287,16 +296,16 @@ async def test_add_job_during_jobs_exec(tmp_path):
)
run_once = False
service = CronService(store_path, on_job=on_job)
service = CronService(store_path, on_job=on_job, max_sleep_ms=100)
service.add_job(
name="heartbeat",
schedule=CronSchedule(kind="every", every_ms=150),
schedule=CronSchedule(kind="every", every_ms=100),
message="tick",
)
assert len(service.list_jobs()) == 1
await service.start()
try:
await asyncio.sleep(3)
await _wait_until(lambda: len(service.list_jobs()) == 2, timeout=0.8)
jobs = service.list_jobs()
assert len(jobs) == 2
assert "test" in [j.name for j in jobs]
+29
View File
@@ -441,6 +441,35 @@ async def test_direct_openai_responses_404_falls_back_to_chat_completions() -> N
mock_chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_direct_openai_open_circuit_skips_responses_api() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_response("from chat"))
mock_responses = AsyncMock(return_value=_fake_responses_response("from responses"))
spec = find_by_name("openai")
with patch("nanobot.providers.openai_compat_provider.AsyncOpenAI") as MockClient:
client_instance = MockClient.return_value
client_instance.chat.completions.create = mock_chat
client_instance.responses.create = mock_responses
provider = OpenAICompatProvider(
api_key="sk-test-key",
default_model="gpt-5-chat",
spec=spec,
)
for _ in range(3):
provider._record_responses_failure("gpt-5-chat", None)
result = await provider.chat(
messages=[{"role": "user", "content": "hello"}],
model="gpt-5-chat",
)
assert result.content == "from chat"
mock_responses.assert_not_awaited()
mock_chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_direct_openai_stream_responses_unsupported_param_falls_back() -> None:
mock_chat = AsyncMock(return_value=_fake_chat_stream("fallback stream"))
@@ -0,0 +1,77 @@
"""Tests for Responses API circuit breaker in OpenAICompatProvider."""
import time
import pytest
from nanobot.providers.openai_compat_provider import (
OpenAICompatProvider,
_RESPONSES_FAILURE_THRESHOLD,
_RESPONSES_PROBE_INTERVAL_S,
)
@pytest.fixture()
def provider():
"""A direct-OpenAI provider with Responses API support."""
p = OpenAICompatProvider.__new__(OpenAICompatProvider)
p.default_model = "gpt-5"
p._spec = type("Spec", (), {"name": "openai"})()
p._effective_base = "https://api.openai.com/v1"
p._responses_failures = {}
p._responses_tripped_at = {}
return p
def test_responses_api_available_by_default(provider):
assert provider._should_use_responses_api("gpt-5", None) is True
def test_circuit_opens_after_threshold(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is False
def test_circuit_does_not_affect_other_models(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("o4-mini", None) is True
def test_success_resets_circuit(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is False
provider._record_responses_success("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is True
def test_probe_after_interval(provider, monkeypatch):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is False
# Fast-forward past the probe interval
key = "gpt-5:"
provider._responses_tripped_at[key] = time.monotonic() - _RESPONSES_PROBE_INTERVAL_S - 1
assert provider._should_use_responses_api("gpt-5", None) is True
def test_below_threshold_still_allows(provider):
provider._record_responses_failure("gpt-5", None)
provider._record_responses_failure("gpt-5", None)
assert provider._should_use_responses_api("gpt-5", None) is True
def test_reasoning_effort_keyed_separately(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("o3", "high")
assert provider._should_use_responses_api("o3", "high") is False
assert provider._should_use_responses_api("o3", "low") is True
def test_reasoning_effort_key_is_case_insensitive(provider):
for _ in range(_RESPONSES_FAILURE_THRESHOLD):
provider._record_responses_failure("o3", "High")
assert provider._should_use_responses_api("o3", "high") is False