Merge remote-tracking branch 'origin/main' into nanobot-webui
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user