mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-13 22:34:06 +00:00
* refactor(dream): replace two-phase Dream class with simple cron + process_direct - Remove the heavyweight Dream class (AgentRunner-based two-phase system) from nanobot/agent/memory.py - Delete dream_phase1.md and dream_phase2.md templates - New dream.md template serves as the consolidation prompt - Cron callback uses agent.process_direct(prompt, session_key=\"dream\") instead of agent.dream.run() - Always performs git auto_commit after execution - /dream command updated to use process_direct + git commit - DreamConfig kept for backward compatibility; deprecated fields (model_override, max_batch_size, max_iterations, annotate_line_ages) are ignored but accepted in config - interval_h remains configurable via agents.defaults.dream.interval_h - Update tests and webui settings to match new architecture * feat(loop): add ephemeral mode to process_direct, skip history writes for Dream When ephemeral=True, _state_save skips enforce_file_cap (which calls raw_archive -> append_history) and consolidator.maybe_consolidate_by_tokens. This prevents Dream sessions from creating a positive feedback loop where they process their own output. The session IS still saved to disk. * fix(loop): skip extra hooks for ephemeral sessions (Dream) * feat(dream): per-run timestamped sessions with rotation for WebUI * test(config): restore DreamConfig schedule and alias tests * fix(dream): include LLM response summary in git auto-commit message The old two-phase Dream class included the Phase 1 analysis in the git commit message body. The new single-phase version lost this. Restore it by extracting resp.content from the process_direct return value and appending it to the commit message in both the cron handler and the /dream command. * fix(test): accept ephemeral kwarg in test_openai_api fake_process * refactor(dream): merge dream_session.py into MemoryStore The standalone dream_session.py module only contained three small helpers that all revolve around MemoryStore concerns (session keys, commit messages, file pruning). Fold them into MemoryStore as @staticmethod to reduce indirection and avoid a 35-line module with no independent reason to exist. * fix(test): address code review — patch correct instance, use actual function - Fix test_ephemeral_skips_raw_archive to patch loop.context.memory instead of the fixture's separate MemoryStore instance - Fix TestDreamCommitMessage to call MemoryStore.build_dream_commit_message instead of reimplementing the logic inline - Move Dream helpers in memory.py above the Consolidator section comment to avoid misleading visual boundary * fix(dream): gate cursor advancement and restrict tools maintainer edit: Dream now processes backlog from the oldest unprocessed entries, only advances the cursor after a completed ephemeral run, and uses a restricted file-only tool registry for background consolidation. * fix(dream): skip idle compact for dream sessions Dream runs use internal dream:* sessions that are pruned by Dream retention. Exclude them from AutoCompact scheduling, archive execution, and summary injection so idle-session compaction cannot truncate Dream transcripts. * fix(dream): keep batched history isolated * feat(dream): tag archived memory for single-phase Dream --------- Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
429 lines
13 KiB
Python
429 lines
13 KiB
Python
"""Focused tests for the fixed-session OpenAI-compatible API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
|
|
from nanobot.api.server import (
|
|
API_CHAT_ID,
|
|
API_SESSION_KEY,
|
|
_chat_completion_response,
|
|
_error_json,
|
|
create_app,
|
|
handle_chat_completions,
|
|
)
|
|
|
|
try:
|
|
from aiohttp.test_utils import TestClient, TestServer
|
|
|
|
HAS_AIOHTTP = True
|
|
except ImportError:
|
|
HAS_AIOHTTP = False
|
|
|
|
pytest_plugins = ("pytest_asyncio",)
|
|
|
|
|
|
def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
|
agent = MagicMock()
|
|
agent.process_direct = AsyncMock(return_value=response_text)
|
|
agent._connect_mcp = AsyncMock()
|
|
agent.close_mcp = AsyncMock()
|
|
return agent
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_agent():
|
|
return _make_mock_agent()
|
|
|
|
|
|
@pytest.fixture
|
|
def app(mock_agent):
|
|
return create_app(mock_agent, model_name="test-model", request_timeout=10.0)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def aiohttp_client():
|
|
clients: list[TestClient] = []
|
|
|
|
async def _make_client(app):
|
|
client = TestClient(TestServer(app))
|
|
await client.start_server()
|
|
clients.append(client)
|
|
return client
|
|
|
|
try:
|
|
yield _make_client
|
|
finally:
|
|
for client in clients:
|
|
await client.close()
|
|
|
|
|
|
def test_error_json() -> None:
|
|
resp = _error_json(400, "bad request")
|
|
assert resp.status == 400
|
|
body = json.loads(resp.body)
|
|
assert body["error"]["message"] == "bad request"
|
|
assert body["error"]["code"] == 400
|
|
|
|
|
|
def test_chat_completion_response() -> None:
|
|
result = _chat_completion_response("hello world", "test-model")
|
|
assert result["object"] == "chat.completion"
|
|
assert result["model"] == "test-model"
|
|
assert result["choices"][0]["message"]["content"] == "hello world"
|
|
assert result["choices"][0]["finish_reason"] == "stop"
|
|
assert result["id"].startswith("chatcmpl-")
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_missing_messages_returns_400(aiohttp_client, app) -> None:
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post("/v1/chat/completions", json={"model": "test"})
|
|
assert resp.status == 400
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_no_user_message_returns_400(aiohttp_client, app) -> None:
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "system", "content": "you are a bot"}]},
|
|
)
|
|
assert resp.status == 400
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_stream_true_returns_sse(aiohttp_client, app) -> None:
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "user", "content": "hello"}], "stream": True},
|
|
)
|
|
assert resp.status == 200
|
|
assert resp.content_type == "text/event-stream"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_model_mismatch_returns_400() -> None:
|
|
request = MagicMock()
|
|
request.json = AsyncMock(
|
|
return_value={
|
|
"model": "other-model",
|
|
"messages": [{"role": "user", "content": "hello"}],
|
|
}
|
|
)
|
|
request.app = {
|
|
"agent_loop": _make_mock_agent(),
|
|
"model_name": "test-model",
|
|
"request_timeout": 10.0,
|
|
"session_lock": asyncio.Lock(),
|
|
}
|
|
|
|
resp = await handle_chat_completions(request)
|
|
assert resp.status == 400
|
|
body = json.loads(resp.body)
|
|
assert "test-model" in body["error"]["message"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_user_message_required() -> None:
|
|
request = MagicMock()
|
|
request.json = AsyncMock(
|
|
return_value={
|
|
"messages": [
|
|
{"role": "user", "content": "hello"},
|
|
{"role": "assistant", "content": "previous reply"},
|
|
],
|
|
}
|
|
)
|
|
request.app = {
|
|
"agent_loop": _make_mock_agent(),
|
|
"model_name": "test-model",
|
|
"request_timeout": 10.0,
|
|
"session_lock": asyncio.Lock(),
|
|
}
|
|
|
|
resp = await handle_chat_completions(request)
|
|
assert resp.status == 400
|
|
body = json.loads(resp.body)
|
|
assert "single user message" in body["error"]["message"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_single_user_message_must_have_user_role() -> None:
|
|
request = MagicMock()
|
|
request.json = AsyncMock(
|
|
return_value={
|
|
"messages": [{"role": "system", "content": "you are a bot"}],
|
|
}
|
|
)
|
|
request.app = {
|
|
"agent_loop": _make_mock_agent(),
|
|
"model_name": "test-model",
|
|
"request_timeout": 10.0,
|
|
"session_lock": asyncio.Lock(),
|
|
}
|
|
|
|
resp = await handle_chat_completions(request)
|
|
assert resp.status == 400
|
|
body = json.loads(resp.body)
|
|
assert "single user message" in body["error"]["message"].lower()
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_successful_request_uses_fixed_api_session(aiohttp_client, mock_agent) -> None:
|
|
app = create_app(mock_agent, model_name="test-model")
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["choices"][0]["message"]["content"] == "mock response"
|
|
assert body["model"] == "test-model"
|
|
mock_agent.process_direct.assert_called_once_with(
|
|
content="hello",
|
|
media=None,
|
|
session_key=API_SESSION_KEY,
|
|
channel="api",
|
|
chat_id=API_CHAT_ID,
|
|
)
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
|
call_log: list[str] = []
|
|
|
|
async def fake_process(content, session_key="", channel="", chat_id="", **kwargs):
|
|
call_log.append(session_key)
|
|
return f"reply to {content}"
|
|
|
|
agent = MagicMock()
|
|
agent.process_direct = fake_process
|
|
agent._connect_mcp = AsyncMock()
|
|
agent.close_mcp = AsyncMock()
|
|
|
|
app = create_app(agent, model_name="m")
|
|
client = await aiohttp_client(app)
|
|
|
|
r1 = await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "user", "content": "first"}]},
|
|
)
|
|
r2 = await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "user", "content": "second"}]},
|
|
)
|
|
|
|
assert r1.status == 200
|
|
assert r2.status == 200
|
|
assert call_log == [API_SESSION_KEY, API_SESSION_KEY]
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
|
order: list[str] = []
|
|
|
|
async def slow_process(content, session_key="", channel="", chat_id="", **kwargs):
|
|
order.append(f"start:{content}")
|
|
await asyncio.sleep(0.1)
|
|
order.append(f"end:{content}")
|
|
return content
|
|
|
|
agent = MagicMock()
|
|
agent.process_direct = slow_process
|
|
agent._connect_mcp = AsyncMock()
|
|
agent.close_mcp = AsyncMock()
|
|
|
|
app = create_app(agent, model_name="m")
|
|
client = await aiohttp_client(app)
|
|
|
|
async def send(msg: str):
|
|
return await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "user", "content": msg}]},
|
|
)
|
|
|
|
r1, r2 = await asyncio.gather(send("first"), send("second"))
|
|
assert r1.status == 200
|
|
assert r2.status == 200
|
|
# Verify serialization: one process must fully finish before the other starts
|
|
if order[0] == "start:first":
|
|
assert order.index("end:first") < order.index("start:second")
|
|
else:
|
|
assert order.index("end:second") < order.index("start:first")
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_models_endpoint(aiohttp_client, app) -> None:
|
|
client = await aiohttp_client(app)
|
|
resp = await client.get("/v1/models")
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["object"] == "list"
|
|
assert body["data"][0]["id"] == "test-model"
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_health_endpoint(aiohttp_client, app) -> None:
|
|
client = await aiohttp_client(app)
|
|
resp = await client.get("/health")
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["status"] == "ok"
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_multimodal_content_extracts_text(aiohttp_client, mock_agent) -> None:
|
|
app = create_app(mock_agent, model_name="m")
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "describe this"},
|
|
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
|
],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
assert resp.status == 200
|
|
call_kwargs = mock_agent.process_direct.call_args.kwargs
|
|
assert call_kwargs["content"] == "describe this"
|
|
assert call_kwargs["session_key"] == API_SESSION_KEY
|
|
assert call_kwargs["channel"] == "api"
|
|
assert call_kwargs["chat_id"] == API_CHAT_ID
|
|
assert len(call_kwargs.get("media") or []) >= 0 # base64 images saved to disk
|
|
|
|
|
|
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
|
@pytest.mark.asyncio
|
|
async def test_multimodal_remote_image_url_returns_400(aiohttp_client, mock_agent) -> None:
|
|
app = create_app(mock_agent, model_name="m")
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{"type": "text", "text": "describe this"},
|
|
{"type": "image_url", "image_url": {"url": "https://example.com/image.png"}},
|
|
],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
assert resp.status == 400
|
|
body = await resp.json()
|
|
assert "remote image urls are not supported" in body["error"]["message"].lower()
|
|
mock_agent.process_direct.assert_not_called()
|
|
|
|
|
|
@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()
|
|
|
|
app = create_app(agent, model_name="m")
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
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_falls_back(aiohttp_client) -> None:
|
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
|
|
|
call_count = 0
|
|
|
|
async def always_empty(content, session_key="", channel="", chat_id="", **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return ""
|
|
|
|
agent = MagicMock()
|
|
agent.process_direct = always_empty
|
|
agent._connect_mcp = AsyncMock()
|
|
agent.close_mcp = AsyncMock()
|
|
|
|
app = create_app(agent, model_name="m")
|
|
client = await aiohttp_client(app)
|
|
resp = await client.post(
|
|
"/v1/chat/completions",
|
|
json={"messages": [{"role": "user", "content": "hello"}]},
|
|
)
|
|
assert resp.status == 200
|
|
body = await resp.json()
|
|
assert body["choices"][0]["message"]["content"] == EMPTY_FINAL_RESPONSE_MESSAGE
|
|
assert call_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_process_direct_accepts_media() -> None:
|
|
"""process_direct should forward media paths to _process_message."""
|
|
from nanobot.agent.loop import AgentLoop
|
|
|
|
loop = AgentLoop.__new__(AgentLoop)
|
|
loop._connect_mcp = AsyncMock()
|
|
loop._session_locks = {}
|
|
|
|
captured_msg = None
|
|
|
|
async def fake_process(msg, *, session_key="", on_progress=None, on_stream=None, on_stream_end=None, ephemeral=False):
|
|
nonlocal captured_msg
|
|
captured_msg = msg
|
|
return None
|
|
|
|
loop._process_message = fake_process
|
|
|
|
await loop.process_direct(
|
|
content="analyze this",
|
|
media=["/tmp/image.png", "/tmp/report.pdf"],
|
|
session_key="test:1",
|
|
)
|
|
|
|
assert captured_msg is not None
|
|
assert captured_msg.media == ["/tmp/image.png", "/tmp/report.pdf"]
|
|
assert captured_msg.content == "analyze this"
|