mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 17:08:33 +00:00
491 lines
20 KiB
Python
491 lines
20 KiB
Python
"""Tests for Dream driven through AgentLoop._process_system_message."""
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent.loop import AgentLoop
|
|
from nanobot.agent.runner import AgentRunResult
|
|
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
|
from nanobot.bus.events import InboundMessage
|
|
from nanobot.bus.queue import MessageBus
|
|
from nanobot.utils.gitstore import LineAge
|
|
|
|
|
|
def _provider(default_model: str, max_tokens: int = 123) -> MagicMock:
|
|
provider = MagicMock()
|
|
provider.get_default_model.return_value = default_model
|
|
provider.generation = SimpleNamespace(
|
|
max_tokens=max_tokens, temperature=0.1, reasoning_effort=None
|
|
)
|
|
return provider
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path):
|
|
from nanobot.agent.memory import MemoryStore
|
|
|
|
s = MemoryStore(tmp_path)
|
|
s.write_soul("# Soul\n- Helpful")
|
|
s.write_user("# User\n- Developer")
|
|
s.write_memory("# Memory\n- Project X active")
|
|
return s
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_provider():
|
|
return _provider("test-model")
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_runner():
|
|
return MagicMock()
|
|
|
|
|
|
@pytest.fixture
|
|
def loop(tmp_path, mock_provider, mock_runner):
|
|
loop = AgentLoop(
|
|
bus=MessageBus(),
|
|
provider=mock_provider,
|
|
workspace=tmp_path,
|
|
model="test-model",
|
|
context_window_tokens=1000,
|
|
)
|
|
loop.dream._runner = mock_runner
|
|
return loop
|
|
|
|
|
|
def _make_run_result(
|
|
stop_reason="completed",
|
|
final_content=None,
|
|
tool_events=None,
|
|
usage=None,
|
|
):
|
|
return AgentRunResult(
|
|
final_content=final_content or stop_reason,
|
|
stop_reason=stop_reason,
|
|
messages=[],
|
|
tools_used=[],
|
|
usage={},
|
|
tool_events=tool_events or [],
|
|
)
|
|
|
|
|
|
class TestDreamAgentLoopIntegration:
|
|
async def test_completes_goal_state_after_full_backlog(self, loop, mock_runner, store):
|
|
"""Goal should be completed after processing all backlog in internal loop."""
|
|
for i in range(6):
|
|
store.append_history(f"event {i}")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
session = loop.sessions.get_or_create("system:dream")
|
|
goal = session.metadata.get("goal_state")
|
|
assert isinstance(goal, dict)
|
|
assert goal["status"] == "completed"
|
|
assert store.get_last_dream_cursor() == 6
|
|
|
|
async def test_completes_goal_state_on_finish(self, loop, mock_runner, store):
|
|
"""Goal should be marked completed when backlog is fully processed."""
|
|
store.append_history("event 1")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
session = loop.sessions.get_or_create("system:dream")
|
|
goal = session.metadata.get("goal_state")
|
|
assert goal["status"] == "completed"
|
|
assert "completed_at" in goal
|
|
assert "recap" in goal
|
|
|
|
async def test_noop_when_no_unprocessed_history(self, loop, mock_runner):
|
|
"""Dream should not call runner when there's nothing to process."""
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
result = await loop._process_system_message(msg)
|
|
assert result is None
|
|
mock_runner.run.assert_not_called()
|
|
|
|
async def test_calls_runner_for_unprocessed_entries(self, loop, mock_runner, store):
|
|
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
|
store.append_history("User prefers dark mode")
|
|
mock_runner.run = AsyncMock(
|
|
return_value=_make_run_result(
|
|
tool_events=[
|
|
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
|
],
|
|
)
|
|
)
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
mock_runner.run.assert_called_once()
|
|
spec = mock_runner.run.call_args[0][0]
|
|
assert spec.max_iterations == 10
|
|
assert spec.fail_on_tool_error is False
|
|
|
|
async def test_advances_dream_cursor(self, loop, mock_runner, store):
|
|
"""Dream should advance the cursor after processing."""
|
|
store.append_history("event 1")
|
|
store.append_history("event 2")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
assert store.get_last_dream_cursor() == 2
|
|
|
|
async def test_compacts_processed_history(self, loop, mock_runner, store):
|
|
"""Dream should compact history after processing."""
|
|
store.append_history("event 1")
|
|
store.append_history("event 2")
|
|
store.append_history("event 3")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
entries = store.read_unprocessed_history(since_cursor=0)
|
|
assert all(e["cursor"] > 0 for e in entries)
|
|
|
|
async def test_processes_full_backlog_in_one_call(self, loop, mock_runner, store):
|
|
"""Backlog larger than max_batch_size should be fully processed in one call."""
|
|
for i in range(12):
|
|
store.append_history(f"event {i}")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
assert store.get_last_dream_cursor() == 12
|
|
assert mock_runner.run.call_count == 3 # 5 + 5 + 2
|
|
|
|
async def test_single_git_commit_for_multi_batch(self, loop, mock_runner, store):
|
|
"""Multi-batch run should collapse into exactly one git commit."""
|
|
store.git.init()
|
|
store.git.auto_commit("initial")
|
|
for i in range(12):
|
|
store.append_history(f"event {i}")
|
|
mock_runner.run = AsyncMock(
|
|
return_value=_make_run_result(
|
|
tool_events=[
|
|
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
|
],
|
|
)
|
|
)
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
commits = store.git.log()
|
|
dream_commits = [c for c in commits if c.message.startswith("dream:")]
|
|
assert len(dream_commits) == 1
|
|
|
|
async def test_system_prompt_cached(self, loop, mock_runner, store):
|
|
"""Batches within one run should reuse cached system prompt when template mtime unchanged."""
|
|
for i in range(6):
|
|
store.append_history(f"event {i}")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
# Two batches (5 + 1), both should use the same cached prompt
|
|
assert mock_runner.run.call_count == 2
|
|
first_prompt = mock_runner.run.call_args_list[0][0][0].initial_messages[0]["content"]
|
|
second_prompt = mock_runner.run.call_args_list[1][0][0].initial_messages[0]["content"]
|
|
assert second_prompt is first_prompt
|
|
|
|
async def test_noop_when_empty_backlog(self, loop, mock_runner, store):
|
|
"""Empty backlog should not advance cursor or create a commit."""
|
|
store.git.init()
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
assert store.get_last_dream_cursor() == 0
|
|
commits = store.git.log()
|
|
assert len([c for c in commits if c.message.startswith("dream:")]) == 0
|
|
|
|
|
|
class TestDreamPrompt:
|
|
async def test_prompt_contains_mece_rules(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
system_prompt = spec.initial_messages[0]["content"]
|
|
assert "Do NOT guess paths" in system_prompt
|
|
assert "SOUL.md" in system_prompt
|
|
assert "USER.md" in system_prompt
|
|
assert "MEMORY.md" in system_prompt
|
|
|
|
async def test_skill_phase_uses_builtin_skill_creator_path(self, loop, mock_runner, store):
|
|
store.append_history("Repeated workflow one")
|
|
store.append_history("Repeated workflow two")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
system_prompt = spec.initial_messages[0]["content"]
|
|
expected = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md")
|
|
assert expected in system_prompt
|
|
|
|
async def test_system_prompt_uses_threshold_from_template_var(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
system_msg = spec.initial_messages[0]["content"]
|
|
assert "N>14" in system_msg
|
|
|
|
|
|
class TestDreamPromptCaps:
|
|
async def test_caps_huge_memory_file(self, loop, mock_runner, store):
|
|
store.write_memory("M" * (loop.dream._MEMORY_FILE_MAX_CHARS * 5))
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
|
"## Current SOUL.md"
|
|
)[0]
|
|
assert len(memory_section) < loop.dream._MEMORY_FILE_MAX_CHARS + 500
|
|
|
|
async def test_caps_huge_history_entry(self, loop, mock_runner, store):
|
|
store.history_file.write_text(
|
|
json.dumps(
|
|
{
|
|
"cursor": 1,
|
|
"timestamp": "2026-04-01 10:00",
|
|
"content": "H" * (loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
|
}
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
history_section = user_msg.split("## Conversation History\n")[1].split(
|
|
"\n\n## Current Date"
|
|
)[0]
|
|
assert len(history_section) < loop.dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
|
|
|
|
|
class TestDreamTools:
|
|
def test_apply_patch_tool_registered(self, loop):
|
|
tool = loop.dream._tools.get("apply_patch")
|
|
assert tool is not None
|
|
|
|
|
|
class TestDreamCaps:
|
|
def test_batch_size_default_is_5(self):
|
|
from nanobot.config.schema import DreamConfig
|
|
|
|
assert DreamConfig().max_batch_size == 5
|
|
|
|
def test_memory_cap_is_16k(self, loop):
|
|
assert loop.dream._MEMORY_FILE_MAX_CHARS == 16_000
|
|
|
|
|
|
class TestDreamSkipFiltering:
|
|
async def test_skip_entries_removed_from_prompt(self, loop, mock_runner, store):
|
|
store.append_history("- [skip] greeting\n- [permanent] User prefers dark mode")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
assert "User prefers dark mode" in user_msg
|
|
assert "[skip]" not in user_msg
|
|
assert "greeting" not in user_msg
|
|
|
|
|
|
class TestDreamAgeAnnotations:
|
|
async def test_prompt_includes_line_age_annotations(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
store.git.init()
|
|
store.git.auto_commit("initial memory state")
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
assert "## Current MEMORY.md" in user_msg
|
|
|
|
async def test_annotates_only_memory_not_soul_or_user(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
store.git.init()
|
|
store.git.auto_commit("initial state")
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
soul_section = user_msg.split("## Current SOUL.md")[1].split(
|
|
"## Current USER.md"
|
|
)[0]
|
|
user_section = user_msg.split("## Current USER.md")[1]
|
|
assert "←" not in soul_section
|
|
assert "←" not in user_section
|
|
|
|
async def test_prompt_works_without_git(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
mock_runner.run.assert_called_once()
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
assert "## Current MEMORY.md" in user_msg
|
|
|
|
async def test_prompt_carries_age_suffix_for_stale_lines(self, loop, mock_runner, store):
|
|
store.write_memory(
|
|
"# Memory\n- Project X active\n- fresh item\n- edge case line"
|
|
)
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
fake_ages = [
|
|
LineAge(age_days=30),
|
|
LineAge(age_days=20),
|
|
LineAge(age_days=14),
|
|
LineAge(age_days=5),
|
|
]
|
|
with patch.object(loop.dream.store.git, "line_ages", return_value=fake_ages):
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
|
"## Current SOUL.md"
|
|
)[0]
|
|
assert "← 30d" in memory_section
|
|
assert "← 20d" in memory_section
|
|
assert "← 14d" not in memory_section
|
|
assert "← 5d" not in memory_section
|
|
|
|
async def test_skips_annotation_when_disabled(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
loop.dream.annotate_line_ages = False
|
|
with patch.object(loop.dream.store.git, "line_ages") as mock_line_ages:
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
mock_line_ages.assert_not_called()
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
assert "←" not in user_msg
|
|
|
|
async def test_skips_annotation_on_line_ages_length_mismatch(self, loop, mock_runner, store):
|
|
store.append_history("some event")
|
|
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
|
with patch.object(
|
|
loop.dream.store.git, "line_ages", return_value=[LineAge(age_days=999)]
|
|
):
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
spec = mock_runner.run.call_args[0][0]
|
|
user_msg = spec.initial_messages[1]["content"]
|
|
memory_section = user_msg.split("## Current MEMORY.md")[1].split(
|
|
"## Current SOUL.md"
|
|
)[0]
|
|
assert "←" not in memory_section
|
|
|
|
|
|
class TestDreamSessionPersistence:
|
|
async def test_writes_session_on_success(self, loop, mock_runner, store):
|
|
store.append_history("event one")
|
|
store.append_history("event two")
|
|
mock_runner.run = AsyncMock(
|
|
return_value=_make_run_result(
|
|
tool_events=[
|
|
{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}
|
|
],
|
|
)
|
|
)
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
session_path = store.memory_dir / ".dream_session.json"
|
|
assert session_path.exists()
|
|
data = json.loads(session_path.read_text(encoding="utf-8"))
|
|
assert data["batch"]["from_cursor"] == 0
|
|
assert data["batch"]["to_cursor"] == 2
|
|
assert data["batch"]["count"] == 2
|
|
assert data["stop_reason"] == "completed"
|
|
assert data["changelog"] == ["edit_file: memory/MEMORY.md"]
|
|
assert "timestamp" in data
|
|
assert "elapsed_seconds" in data
|
|
assert "messages" in data
|
|
|
|
async def test_no_session_record_on_failure(self, loop, mock_runner, store):
|
|
"""Failed batch should not write a session record (cursor stays put for retry)."""
|
|
store.append_history("event one")
|
|
mock_runner.run = AsyncMock(side_effect=RuntimeError("LLM error"))
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
session_path = store.memory_dir / ".dream_session.json"
|
|
assert not session_path.exists()
|
|
assert store.get_last_dream_cursor() == 0
|
|
|
|
async def test_session_contains_full_messages(self, loop, mock_runner, store):
|
|
store.append_history("event one")
|
|
messages = [
|
|
{"role": "system", "content": "you are a memory bot"},
|
|
{"role": "user", "content": "history here"},
|
|
{"role": "assistant", "content": "I will edit MEMORY.md"},
|
|
]
|
|
result = _make_run_result()
|
|
result.messages = messages
|
|
mock_runner.run = AsyncMock(return_value=result)
|
|
msg = InboundMessage(
|
|
channel="system", sender_id="dream", chat_id="dream", content=""
|
|
)
|
|
await loop._process_system_message(msg)
|
|
session_path = store.memory_dir / ".dream_session.json"
|
|
data = json.loads(session_path.read_text(encoding="utf-8"))
|
|
assert data["messages"] == messages
|
|
assert data["prompt_chars"] > 0
|
|
assert data["commit_sha"] is None
|