test: improve deterministic unit test coverage
This commit is contained in:
@@ -111,6 +111,13 @@ def _make_fake_compact(
|
||||
return _fake_compact
|
||||
|
||||
|
||||
async def _drain_background_tasks(loop: AgentLoop) -> None:
|
||||
tasks = list(loop._background_tasks)
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
class TestSessionTTLConfig:
|
||||
"""Test session TTL configuration."""
|
||||
|
||||
@@ -269,7 +276,7 @@ class TestAutoCompact:
|
||||
|
||||
loop.consolidator.compact_idle_session = _make_fake_compact(loop)
|
||||
loop.auto_compact.check_expired(loop._schedule_background)
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_background_tasks(loop)
|
||||
|
||||
active_after = loop.sessions.get_or_create("cli:active")
|
||||
assert len(active_after.messages) == 1
|
||||
@@ -710,7 +717,7 @@ class TestProactiveAutoCompact:
|
||||
loop._schedule_background,
|
||||
active_session_keys=active_session_keys,
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_background_tasks(loop)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_check_when_ttl_disabled(self, tmp_path):
|
||||
@@ -815,12 +822,11 @@ class TestProactiveAutoCompact:
|
||||
|
||||
# Second call should skip (key is in _archiving)
|
||||
loop.auto_compact.check_expired(loop._schedule_background)
|
||||
await asyncio.sleep(0.05)
|
||||
assert archive_count == 1
|
||||
|
||||
# Clean up
|
||||
block_forever.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_background_tasks(loop)
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Test session management with cache-friendly message handling."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
|
||||
# Test constants
|
||||
@@ -603,9 +604,10 @@ class TestNewCommandArchival:
|
||||
loop.sessions.save(session)
|
||||
|
||||
archived = asyncio.Event()
|
||||
release_archive = asyncio.Event()
|
||||
|
||||
async def _slow_summarize(_messages) -> bool:
|
||||
await asyncio.sleep(0.1)
|
||||
await release_archive.wait()
|
||||
archived.set()
|
||||
return True
|
||||
|
||||
@@ -615,5 +617,6 @@ class TestNewCommandArchival:
|
||||
await loop._process_message(new_msg)
|
||||
|
||||
assert not archived.is_set()
|
||||
release_archive.set()
|
||||
await loop.close_mcp()
|
||||
assert archived.is_set()
|
||||
|
||||
@@ -9,6 +9,7 @@ from nanobot.agent.memory import (
|
||||
Consolidator,
|
||||
MemoryStore,
|
||||
)
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
@@ -497,11 +498,12 @@ class TestCompactIdleSession:
|
||||
|
||||
# Use a slow LLM response to ensure the lock is held while we check
|
||||
started = asyncio.Event()
|
||||
release_chat = asyncio.Event()
|
||||
|
||||
async def slow_chat(**kwargs):
|
||||
started.set()
|
||||
await asyncio.sleep(0.1)
|
||||
return MagicMock(content="Summary.", finish_reason="stop")
|
||||
await release_chat.wait()
|
||||
return LLMResponse(content="Summary.", finish_reason="stop")
|
||||
|
||||
mock_provider.chat_with_retry = slow_chat
|
||||
|
||||
@@ -520,6 +522,7 @@ class TestCompactIdleSession:
|
||||
)
|
||||
await started.wait()
|
||||
assert lock.locked()
|
||||
release_chat.set()
|
||||
await task
|
||||
assert not lock.locked()
|
||||
|
||||
|
||||
@@ -134,9 +134,7 @@ class TestEphemeralDirect:
|
||||
provider.supports_tools = True
|
||||
provider.generation = MagicMock(max_tokens=4096)
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
return_value=MagicMock(
|
||||
content="done", finish_reason="stop", tool_calls=[], usage={},
|
||||
)
|
||||
return_value=LLMResponse(content="done", tool_calls=[], finish_reason="stop", usage={})
|
||||
)
|
||||
|
||||
with (
|
||||
@@ -168,9 +166,13 @@ class TestEphemeralDirect:
|
||||
mock_archive.assert_not_called()
|
||||
|
||||
async def test_non_ephemeral_runs_normally(self, tmp_path, _make_loop):
|
||||
"""Without ephemeral, the normal path is untouched — no crash."""
|
||||
"""Without ephemeral, the normal path returns the model response."""
|
||||
loop, store = _make_loop
|
||||
await loop.process_direct("test", session_key="cli:normal")
|
||||
response = await loop.process_direct("test", session_key="cli:normal")
|
||||
|
||||
assert response is not None
|
||||
assert response.content == "done"
|
||||
loop.provider.chat_with_retry.assert_awaited()
|
||||
|
||||
async def test_ephemeral_sets_ctx_flag(self, tmp_path, _make_loop):
|
||||
"""Verify that ephemeral=True is forwarded to TurnContext."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tests for Dream session key generation and rotation."""
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
|
||||
@@ -13,9 +13,12 @@ class TestDreamSessionKey:
|
||||
datetime.strptime(ts_part, "%Y%m%d-%H%M%S")
|
||||
|
||||
def test_unique_across_calls(self):
|
||||
k1 = MemoryStore.dream_session_key()
|
||||
time.sleep(1.1)
|
||||
k2 = MemoryStore.dream_session_key()
|
||||
now = datetime(2026, 5, 28, 10, 0, 0)
|
||||
with patch("nanobot.agent.memory.datetime") as mock_dt:
|
||||
mock_dt.now.side_effect = [now, now + timedelta(seconds=1)]
|
||||
k1 = MemoryStore.dream_session_key()
|
||||
k2 = MemoryStore.dream_session_key()
|
||||
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
@@ -62,3 +65,4 @@ class TestPruneDreamSessions:
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
assert list(sessions_dir.iterdir()) == []
|
||||
|
||||
@@ -21,7 +21,8 @@ def _ctx() -> AgentHookContext:
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_hook_emit_reasoning_is_noop():
|
||||
hook = AgentHook()
|
||||
await hook.emit_reasoning("should not raise")
|
||||
result = await hook.emit_reasoning("should not raise")
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -5,12 +5,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
|
||||
_MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
@@ -18,7 +17,7 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_preserves_reasoning_fields_and_tool_results():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
captured_second_call: list[dict] = []
|
||||
@@ -75,7 +74,7 @@ async def test_runner_preserves_reasoning_fields_and_tool_results():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_returns_max_iterations_fallback():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(
|
||||
@@ -106,7 +105,7 @@ async def test_runner_returns_max_iterations_fallback():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_times_out_hung_llm_request():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
|
||||
@@ -136,15 +135,15 @@ async def test_runner_times_out_hung_llm_request():
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests():
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
streamed: list[str] = []
|
||||
|
||||
async def chat_stream_with_retry(*, on_content_delta, **kwargs):
|
||||
await asyncio.sleep(0.08)
|
||||
await asyncio.sleep(0)
|
||||
await on_content_delta("still ")
|
||||
await asyncio.sleep(0.08)
|
||||
await asyncio.sleep(0)
|
||||
await on_content_delta("alive")
|
||||
return LLMResponse(content="still alive", tool_calls=[])
|
||||
|
||||
@@ -161,25 +160,28 @@ async def test_runner_does_not_apply_outer_wall_timeout_to_streaming_requests():
|
||||
streamed.append(delta)
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "think for a while"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=StreamingHook(),
|
||||
llm_timeout_s=0.01,
|
||||
))
|
||||
wait_for = AsyncMock(side_effect=AssertionError("streaming path must not use wait_for"))
|
||||
with patch("nanobot.agent.runner.asyncio.wait_for", wait_for):
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "think for a while"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
hook=StreamingHook(),
|
||||
llm_timeout_s=0.01,
|
||||
))
|
||||
|
||||
assert result.stop_reason == "completed"
|
||||
assert result.final_content == "still alive"
|
||||
assert streamed == ["still ", "alive"]
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
wait_for.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_replaces_empty_tool_result_with_marker():
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
captured_second_call: list[dict] = []
|
||||
@@ -218,7 +220,7 @@ async def test_runner_replaces_empty_tool_result_with_marker():
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_retries_empty_final_response_with_summary_prompt():
|
||||
"""Empty responses get 2 silent retries before finalization kicks in."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
calls: list[dict] = []
|
||||
@@ -263,7 +265,7 @@ async def test_runner_retries_empty_final_response_with_summary_prompt():
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_uses_specific_message_after_empty_finalization_retry():
|
||||
"""After silent retries + finalization all return empty, stop_reason is empty_final_response."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
@@ -295,7 +297,7 @@ async def test_runner_empty_response_does_not_break_tool_chain():
|
||||
Sequence: tool_call -> empty -> tool_call -> final text.
|
||||
The runner should recover via silent retry and complete normally.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
call_count = 0
|
||||
@@ -352,7 +354,7 @@ async def test_runner_empty_response_does_not_break_tool_chain():
|
||||
async def test_runner_accumulates_usage_and_preserves_cached_tokens():
|
||||
"""Runner should accumulate prompt/completion tokens across iterations
|
||||
and preserve cached_tokens from provider responses."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
call_count = {"n": 0}
|
||||
@@ -399,7 +401,7 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
|
||||
internal retry diagnostics like "Model request failed, retry in 1s"
|
||||
to leak to end-user channels as normal progress updates.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
@@ -441,7 +443,7 @@ async def test_runner_binds_on_retry_wait_to_retry_callback_not_progress():
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_passes_temperature_to_provider():
|
||||
"""temperature from AgentRunSpec should reach provider.chat_with_retry."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
@@ -470,7 +472,7 @@ async def test_runner_passes_temperature_to_provider():
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_passes_max_tokens_to_provider():
|
||||
"""max_tokens from AgentRunSpec should reach provider.chat_with_retry."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
@@ -499,7 +501,7 @@ async def test_runner_passes_max_tokens_to_provider():
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_passes_reasoning_effort_to_provider():
|
||||
"""reasoning_effort from AgentRunSpec should reach provider.chat_with_retry."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -35,15 +34,15 @@ def _make_loop(tmp_path):
|
||||
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path)
|
||||
return loop
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_returns_empty_when_no_callback():
|
||||
"""No injection_callback → empty list."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
@@ -61,7 +60,7 @@ async def test_drain_injections_returns_empty_when_no_callback():
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_extracts_content_from_inbound_messages():
|
||||
"""Should extract .content from InboundMessage objects."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -92,7 +91,7 @@ async def test_drain_injections_extracts_content_from_inbound_messages():
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_passes_limit_to_callback_when_supported():
|
||||
"""Limit-aware callbacks can preserve overflow in their own queue."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _MAX_INJECTIONS_PER_TURN
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -127,7 +126,7 @@ async def test_drain_injections_passes_limit_to_callback_when_supported():
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_skips_empty_content():
|
||||
"""Messages with blank content should be filtered out."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -156,7 +155,7 @@ async def test_drain_injections_skips_empty_content():
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_handles_callback_exception():
|
||||
"""If the callback raises, return empty list (error is logged)."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
@@ -178,7 +177,7 @@ async def test_drain_injections_handles_callback_exception():
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint1_injects_after_tool_execution():
|
||||
"""Follow-up messages are injected after tool execution, before next LLM call."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -231,8 +230,8 @@ async def test_checkpoint1_injects_after_tool_execution():
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
|
||||
"""After final response, if injections exist, stream_end should get resuming=True."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -290,7 +289,7 @@ async def test_checkpoint2_injects_after_final_response_with_resuming_stream():
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkpoint2_preserves_final_response_in_history_before_followup():
|
||||
"""A follow-up injected after a final answer must still see that answer in history."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -405,7 +404,7 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_merges_multiple_injected_user_messages_without_losing_media():
|
||||
"""Multiple injected follow-ups should not create lossy consecutive user messages."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
call_count = {"n": 0}
|
||||
@@ -468,7 +467,7 @@ async def test_runner_merges_multiple_injected_user_messages_without_losing_medi
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection_cycles_capped_at_max():
|
||||
"""Injection cycles should be capped at _MAX_INJECTION_CYCLES."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _MAX_INJECTION_CYCLES
|
||||
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -509,7 +508,7 @@ async def test_injection_cycles_capped_at_max():
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_injections_flag_is_false_by_default():
|
||||
"""had_injections should be False when no injection callback or no messages."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
@@ -607,16 +606,12 @@ async def test_followup_routed_to_pending_queue(tmp_path):
|
||||
msg = InboundMessage(channel="discord", sender_id="u", chat_id="c", content="follow-up")
|
||||
await loop.bus.publish_inbound(msg)
|
||||
|
||||
deadline = time.time() + 2
|
||||
while pending.empty() and time.time() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
queued_msg = await asyncio.wait_for(pending.get(), timeout=2)
|
||||
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
|
||||
assert loop._dispatch.await_count == 0
|
||||
assert not pending.empty()
|
||||
queued_msg = pending.get_nowait()
|
||||
assert queued_msg.content == "follow-up"
|
||||
assert queued_msg.session_key == UNIFIED_SESSION_KEY
|
||||
|
||||
@@ -625,9 +620,9 @@ async def test_followup_routed_to_pending_queue(tmp_path):
|
||||
async def test_pending_queue_preserves_overflow_for_next_injection_cycle(tmp_path):
|
||||
"""Pending queue should leave overflow messages queued for later drains."""
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
@@ -680,7 +675,12 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
loop = _make_loop(tmp_path)
|
||||
loop._dispatch = AsyncMock() # type: ignore[method-assign]
|
||||
dispatched = asyncio.Event()
|
||||
|
||||
async def _dispatch(_msg):
|
||||
dispatched.set()
|
||||
|
||||
loop._dispatch = AsyncMock(side_effect=_dispatch) # type: ignore[method-assign]
|
||||
|
||||
pending = asyncio.Queue(maxsize=1)
|
||||
pending.put_nowait(InboundMessage(channel="cli", sender_id="u", chat_id="c", content="already queued"))
|
||||
@@ -690,9 +690,7 @@ async def test_pending_queue_full_falls_back_to_queued_task(tmp_path):
|
||||
msg = InboundMessage(channel="cli", sender_id="u", chat_id="c", content="follow-up")
|
||||
await loop.bus.publish_inbound(msg)
|
||||
|
||||
deadline = time.time() + 2
|
||||
while loop._dispatch.await_count == 0 and time.time() < deadline:
|
||||
await asyncio.sleep(0.01)
|
||||
await asyncio.wait_for(dispatched.wait(), timeout=2)
|
||||
|
||||
loop.stop()
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
@@ -750,7 +748,7 @@ async def test_dispatch_republishes_leftover_queue_messages(tmp_path):
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_on_fatal_tool_error():
|
||||
"""Pending injections should be drained even when a fatal tool error occurs."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -803,7 +801,7 @@ async def test_drain_injections_on_fatal_tool_error():
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_on_llm_error():
|
||||
"""Pending injections should be drained when the LLM returns an error finish_reason."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -858,7 +856,7 @@ async def test_drain_injections_on_llm_error():
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_on_empty_final_response():
|
||||
"""Pending injections should be drained when the runner exits due to empty response."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _MAX_EMPTY_RETRIES
|
||||
from nanobot.agent.runner import _MAX_EMPTY_RETRIES, AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -913,7 +911,7 @@ async def test_drain_injections_on_max_iterations():
|
||||
injections are appended to messages but not processed by the LLM.
|
||||
The key point is they are consumed from the queue to prevent re-publish.
|
||||
"""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -965,7 +963,7 @@ async def test_drain_injections_on_max_iterations():
|
||||
async def test_drain_injections_set_flag_when_followup_arrives_after_last_iteration():
|
||||
"""Late follow-ups drained in max_iterations should still flip had_injections."""
|
||||
from nanobot.agent.hook import AgentHook
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
@@ -1027,7 +1025,7 @@ async def test_drain_injections_set_flag_when_followup_arrives_after_last_iterat
|
||||
@pytest.mark.asyncio
|
||||
async def test_injection_cycle_cap_on_error_path():
|
||||
"""Injection cycles should be capped even when every iteration hits an LLM error."""
|
||||
from nanobot.agent.runner import AgentRunSpec, AgentRunner, _MAX_INJECTION_CYCLES
|
||||
from nanobot.agent.runner import _MAX_INJECTION_CYCLES, AgentRunner, AgentRunSpec
|
||||
from nanobot.bus.events import InboundMessage
|
||||
|
||||
provider = MagicMock()
|
||||
|
||||
@@ -110,6 +110,7 @@ def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():
|
||||
|
||||
history = session.get_history(max_messages=100)
|
||||
_assert_no_orphans(history)
|
||||
assert history[-1]["content"] == "new telegram question"
|
||||
|
||||
|
||||
# --- Positive test: legitimate pairs survive trimming ---
|
||||
@@ -363,6 +364,7 @@ def test_window_cuts_mid_tool_group():
|
||||
# leaving orphan tool results for split_a at the front.
|
||||
history = session.get_history(max_messages=6)
|
||||
_assert_no_orphans(history)
|
||||
assert history[0]["role"] == "user"
|
||||
|
||||
|
||||
# --- Image breadcrumbs: media kwarg is synthesized into content for replay ---
|
||||
@@ -599,8 +601,6 @@ def test_enforce_file_cap_no_duplicate_archive_in_else_branch():
|
||||
archive_fn = MagicMock()
|
||||
session.enforce_file_cap(on_archive=archive_fn, limit=6)
|
||||
|
||||
# Verify retained messages
|
||||
retained_contents = [m["content"] for m in session.messages]
|
||||
assert len(session.messages) <= 6
|
||||
|
||||
# Verify archived messages have NO overlap with retained
|
||||
|
||||
@@ -17,7 +17,6 @@ from nanobot.agent.subagent import (
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -51,6 +50,13 @@ def _make_hook_context(**overrides) -> AgentHookContext:
|
||||
return AgentHookContext(**defaults)
|
||||
|
||||
|
||||
async def _drain_subagent_tasks(sm: SubagentManager) -> None:
|
||||
tasks = list(sm._running_tasks.values())
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SubagentStatus defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -114,7 +120,7 @@ class TestSpawn:
|
||||
assert len(sm._running_tasks) == 1
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
assert len(sm._running_tasks) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -124,7 +130,7 @@ class TestSpawn:
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
await sm.spawn("my task")
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
# Status cleaned up after task completes
|
||||
assert len(sm._task_statuses) == 0
|
||||
|
||||
@@ -142,7 +148,7 @@ class TestSpawn:
|
||||
assert len(sm._session_tasks["s1"]) == 1
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
assert "s1" not in sm._session_tasks
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -158,7 +164,7 @@ class TestSpawn:
|
||||
assert len(sm._session_tasks) == 0
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_label_defaults_to_truncated_task(self, tmp_path):
|
||||
@@ -175,7 +181,7 @@ class TestSpawn:
|
||||
assert status.label == long_task[:30] + "..."
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_label(self, tmp_path):
|
||||
@@ -191,7 +197,7 @@ class TestSpawn:
|
||||
assert status.label == "Custom Label"
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cleanup_callback_removes_all_entries(self, tmp_path):
|
||||
@@ -200,7 +206,7 @@ class TestSpawn:
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
await sm.spawn("task", session_key="s1")
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
assert len(sm._running_tasks) == 0
|
||||
assert len(sm._task_statuses) == 0
|
||||
assert len(sm._session_tasks) == 0
|
||||
@@ -452,7 +458,7 @@ class TestCancelBySession:
|
||||
count = await sm.cancel_by_session("s1")
|
||||
assert count == 2
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_tasks_returns_zero(self, tmp_path):
|
||||
@@ -467,7 +473,7 @@ class TestCancelBySession:
|
||||
final_content="done", messages=[], stop_reason="completed",
|
||||
))
|
||||
await sm.spawn("task1", session_key="s1")
|
||||
await asyncio.sleep(0.1) # Wait for completion
|
||||
await _drain_subagent_tasks(sm)
|
||||
|
||||
count = await sm.cancel_by_session("s1")
|
||||
assert count == 0
|
||||
@@ -499,7 +505,7 @@ class TestRunningCounts:
|
||||
assert sm.get_running_count_by_session("s1") == 2
|
||||
|
||||
block.set()
|
||||
await asyncio.sleep(0.1)
|
||||
await _drain_subagent_tasks(sm)
|
||||
assert sm.get_running_count() == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -521,8 +527,9 @@ class TestSubagentHook:
|
||||
tool_call.name = "read_file"
|
||||
tool_call.arguments = {"path": "/tmp/test"}
|
||||
ctx = _make_hook_context(tool_calls=[tool_call])
|
||||
# Should not raise
|
||||
await hook.before_execute_tools(ctx)
|
||||
result = await hook.before_execute_tools(ctx)
|
||||
assert result is None
|
||||
assert ctx.tool_calls == [tool_call]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_updates_status(self):
|
||||
@@ -544,8 +551,9 @@ class TestSubagentHook:
|
||||
async def test_after_iteration_no_status_noop(self):
|
||||
hook = _SubagentHook("t1", status=None)
|
||||
ctx = _make_hook_context(iteration=5)
|
||||
# Should not raise
|
||||
await hook.after_iteration(ctx)
|
||||
result = await hook.after_iteration(ctx)
|
||||
assert result is None
|
||||
assert ctx.iteration == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_after_iteration_sets_error(self):
|
||||
|
||||
@@ -27,8 +27,8 @@ def _make_loop(*, tools_config=None):
|
||||
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr:
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=workspace, tools_config=tools_config)
|
||||
return loop, bus
|
||||
|
||||
@@ -103,8 +103,8 @@ class TestHandleStop:
|
||||
|
||||
class TestDispatch:
|
||||
def test_exec_tool_not_registered_when_disabled(self):
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
loop, _bus = _make_loop(tools_config=ToolsConfig(exec=ExecToolConfig(enable=False)))
|
||||
|
||||
@@ -166,10 +166,14 @@ class TestDispatch:
|
||||
|
||||
loop, bus = _make_loop()
|
||||
order = []
|
||||
first_started = asyncio.Event()
|
||||
release_first = asyncio.Event()
|
||||
|
||||
async def mock_process(m, **kwargs):
|
||||
order.append(f"start-{m.content}")
|
||||
await asyncio.sleep(0.05)
|
||||
if m.content == "a":
|
||||
first_started.set()
|
||||
await release_first.wait()
|
||||
order.append(f"end-{m.content}")
|
||||
return OutboundMessage(channel="test", chat_id="c1", content=m.content)
|
||||
|
||||
@@ -178,7 +182,12 @@ class TestDispatch:
|
||||
msg2 = InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="b")
|
||||
|
||||
t1 = asyncio.create_task(loop._dispatch(msg1))
|
||||
await asyncio.wait_for(first_started.wait(), timeout=1.0)
|
||||
t2 = asyncio.create_task(loop._dispatch(msg2))
|
||||
await asyncio.sleep(0)
|
||||
assert order == ["start-a"]
|
||||
|
||||
release_first.set()
|
||||
await asyncio.gather(t1, t2)
|
||||
assert order == ["start-a", "end-a", "start-b", "end-b"]
|
||||
|
||||
@@ -286,8 +295,8 @@ class TestSubagentCancellation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_exec_tool_not_registered_when_disabled(self, tmp_path):
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
bus = MessageBus()
|
||||
|
||||
@@ -231,12 +231,14 @@ class TestModifyRestricted:
|
||||
async def test_modify_string_int_coerced(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="max_iterations", value="80")
|
||||
assert "Set max_iterations" in result
|
||||
assert tool._runtime_state.max_iterations == 80
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_context_window_valid(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="context_window_tokens", value=131072)
|
||||
assert "Set context_window_tokens" in result
|
||||
assert tool._runtime_state.context_window_tokens == 131072
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -337,12 +339,14 @@ class TestModifyFree:
|
||||
async def test_modify_allows_list(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="items", value=[1, 2, 3])
|
||||
assert result == "Set scratchpad.items = [1, 2, 3]"
|
||||
assert tool._runtime_state._runtime_vars["items"] == [1, 2, 3]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_modify_allows_dict(self):
|
||||
tool = _make_tool()
|
||||
result = await tool.execute(action="set", key="data", value={"a": 1})
|
||||
assert result == "Set scratchpad.data = {'a': 1}"
|
||||
assert tool._runtime_state._runtime_vars["data"] == {"a": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -743,7 +747,10 @@ class TestSubagentHookStatus:
|
||||
|
||||
hook = _SubagentHook("test")
|
||||
context = AgentHookContext(iteration=1, messages=[])
|
||||
await hook.after_iteration(context) # should not raise
|
||||
result = await hook.after_iteration(context)
|
||||
|
||||
assert result is None
|
||||
assert context.iteration == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,8 +16,8 @@ _MAX_TOOL_RESULT_CHARS = AgentDefaults().max_tool_result_chars
|
||||
async def test_subagent_exec_tool_receives_allowed_env_keys(tmp_path):
|
||||
"""allowed_env_keys from ExecToolConfig must be forwarded to the subagent's ExecTool."""
|
||||
from nanobot.agent.subagent import SubagentManager, SubagentStatus
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
|
||||
bus = MessageBus()
|
||||
@@ -340,8 +340,8 @@ async def test_drain_pending_blocks_while_subagents_running(tmp_path):
|
||||
# With sub-agents running and an empty queue, it should block
|
||||
drain_task = asyncio.create_task(injection_callback())
|
||||
|
||||
# Give it a moment to enter the blocking wait
|
||||
await asyncio.sleep(0.05)
|
||||
# Let the task enter the blocking queue wait.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Should still be running (blocked on pending_queue.get())
|
||||
assert not drain_task.done(), "drain should block while sub-agents are running"
|
||||
@@ -467,9 +467,12 @@ async def test_drain_pending_timeout(tmp_path):
|
||||
|
||||
assert injection_callback is not None
|
||||
|
||||
# Patch the timeout to be very short for testing
|
||||
with patch("nanobot.agent.loop.asyncio.wait_for") as mock_wait:
|
||||
mock_wait.side_effect = asyncio.TimeoutError
|
||||
# Patch the timeout path without leaking the queue.get() coroutine.
|
||||
async def _timeout(awaitable, timeout):
|
||||
awaitable.close()
|
||||
raise asyncio.TimeoutError
|
||||
|
||||
with patch("nanobot.agent.loop.asyncio.wait_for", side_effect=_timeout):
|
||||
results = await injection_callback()
|
||||
assert results == []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user