refactor(dream): replace two-phase Dream class with simple cron + process_direct (#3990)
* 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>
This commit is contained in:
@@ -751,6 +751,27 @@ class TestProactiveAutoCompact:
|
||||
assert entry[0] == "User chatted about old things."
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_proactive_archive_skips_dream_sessions(self, tmp_path):
|
||||
"""Internal Dream sessions should be left to Dream retention, not idle compact."""
|
||||
loop = _make_loop(tmp_path, session_ttl_minutes=15)
|
||||
session = loop.sessions.get_or_create("dream:20260602-155256")
|
||||
_add_turns(session, 6, prefix="dream")
|
||||
session.updated_at = datetime.now() - timedelta(minutes=20)
|
||||
loop.sessions.save(session)
|
||||
|
||||
_fake_compact = _make_fake_compact(loop)
|
||||
loop.consolidator.compact_idle_session = _fake_compact
|
||||
|
||||
await self._run_check_expired(loop)
|
||||
|
||||
session_after = loop.sessions.get_or_create("dream:20260602-155256")
|
||||
assert len(session_after.messages) == 12
|
||||
assert _fake_compact.state["count"] == 0
|
||||
assert "dream:20260602-155256" not in loop.auto_compact._archiving
|
||||
assert "dream:20260602-155256" not in loop.auto_compact._summaries
|
||||
await loop.close_mcp()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_proactive_archive_when_active(self, tmp_path):
|
||||
"""Recently active session should NOT be archived on idle tick."""
|
||||
|
||||
@@ -203,9 +203,15 @@ class TestCheckExpired:
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [{"key": "cli:old", "updated_at": old_ts}]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
|
||||
scheduled = []
|
||||
|
||||
def scheduler(coro):
|
||||
scheduled.append(coro)
|
||||
coro.close()
|
||||
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_called_once()
|
||||
assert len(scheduled) == 1
|
||||
assert "cli:old" in ac._archiving
|
||||
|
||||
def test_active_session_key_skips(self):
|
||||
@@ -251,6 +257,22 @@ class TestCheckExpired:
|
||||
ac.check_expired(scheduler)
|
||||
scheduler.assert_not_called()
|
||||
|
||||
def test_dream_session_skips(self):
|
||||
"""Internal Dream sessions should not be scheduled for idle compact."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
old_ts = (datetime.now() - timedelta(minutes=20)).isoformat()
|
||||
mock_sm.list_sessions.return_value = [
|
||||
{"key": "dream:20260602-155256", "updated_at": old_ts},
|
||||
]
|
||||
ac.sessions = mock_sm
|
||||
scheduler = MagicMock()
|
||||
|
||||
ac.check_expired(scheduler)
|
||||
|
||||
scheduler.assert_not_called()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _archive
|
||||
@@ -273,6 +295,17 @@ class TestArchiveDelegates:
|
||||
"cli:test", ac._RECENT_SUFFIX_MESSAGES,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dream_session_is_ignored(self):
|
||||
ac = _make_autocompact()
|
||||
ac.consolidator.compact_idle_session = AsyncMock(return_value="Summary.")
|
||||
ac._archiving.add("dream:20260602-155256")
|
||||
|
||||
await ac._archive("dream:20260602-155256")
|
||||
|
||||
ac.consolidator.compact_idle_session.assert_not_awaited()
|
||||
assert "dream:20260602-155256" not in ac._archiving
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_populates_summaries_from_metadata(self):
|
||||
ac = _make_autocompact()
|
||||
@@ -416,6 +449,33 @@ class TestPrepareSession:
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
|
||||
def test_dream_session_skips_reload_and_summaries(self):
|
||||
"""Internal Dream sessions should not reload or receive compact summaries."""
|
||||
ac = _make_autocompact(ttl=15)
|
||||
mock_sm = MagicMock(spec=SessionManager)
|
||||
ac.sessions = mock_sm
|
||||
key = "dream:20260602-155256"
|
||||
ac._archiving.add(key)
|
||||
ac._summaries[key] = ("Hot summary.", datetime(2026, 6, 2, 15, 52, 56))
|
||||
session = _make_session(
|
||||
key=key,
|
||||
updated_at=datetime.now() - timedelta(minutes=20),
|
||||
metadata={
|
||||
"_last_summary": {
|
||||
"text": "Cold summary.",
|
||||
"last_active": "2026-06-02T15:52:56",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
result_session, summary = ac.prepare_session(session, key)
|
||||
|
||||
mock_sm.get_or_create.assert_not_called()
|
||||
assert result_session is session
|
||||
assert summary is None
|
||||
assert key not in ac._archiving
|
||||
assert key not in ac._summaries
|
||||
|
||||
def test_cold_path_metadata_not_dict_returns_none(self):
|
||||
"""If metadata _last_summary is not a dict, should return None summary."""
|
||||
ac = _make_autocompact()
|
||||
|
||||
@@ -10,6 +10,7 @@ from nanobot.agent.memory import (
|
||||
MemoryStore,
|
||||
)
|
||||
from nanobot.session.manager import Session
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -76,6 +77,17 @@ class TestConsolidatorSummarize:
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConsolidatorPromptContract:
|
||||
def test_archive_prompt_outputs_attribute_tags_without_missing_context_claims(self):
|
||||
prompt = render_template("agent/consolidator_archive.md", strip=True)
|
||||
|
||||
assert "SNIP" in prompt
|
||||
for mark in ("[permanent]", "[durable]", "[ephemeral]", "[correction]", "[skip]"):
|
||||
assert mark in prompt
|
||||
assert "check context below" not in prompt.lower()
|
||||
assert "Do not mark something [skip] merely because it might already exist" in prompt
|
||||
|
||||
|
||||
class TestConsolidatorArchiveErrorHandling:
|
||||
"""archive() must fall back to raw_archive when the LLM returns an error
|
||||
response (finish_reason == 'error'), e.g. overloaded / quota exceeded.
|
||||
|
||||
+364
-270
@@ -1,309 +1,403 @@
|
||||
"""Tests for the Dream class — two-phase memory consolidation via AgentRunner."""
|
||||
|
||||
import json
|
||||
"""Tests for Dream memory consolidation — build_dream_prompt and cursor management."""
|
||||
|
||||
import pytest
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.memory import Dream, MemoryStore
|
||||
from nanobot.agent.runner import AgentRunResult
|
||||
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
|
||||
from nanobot.utils.gitstore import LineAge
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.providers.base import LLMResponse
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
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():
|
||||
p = MagicMock()
|
||||
p.chat_with_retry = AsyncMock()
|
||||
return p
|
||||
class TestBuildDreamPrompt:
|
||||
def test_returns_none_when_no_history(self, store):
|
||||
assert store.build_dream_prompt() is None
|
||||
|
||||
def test_returns_prompt_with_history(self, store):
|
||||
store.append_history("hello")
|
||||
result = store.build_dream_prompt()
|
||||
assert result is not None
|
||||
prompt, cursor = result
|
||||
assert cursor > 0
|
||||
assert "## Conversation History" in prompt
|
||||
assert "hello" in prompt
|
||||
|
||||
@pytest.fixture
|
||||
def mock_runner():
|
||||
return MagicMock()
|
||||
def test_cursor_advances_only_new_entries(self, store):
|
||||
store.append_history("first")
|
||||
r1 = store.build_dream_prompt()
|
||||
assert r1 is not None
|
||||
_, c1 = r1
|
||||
|
||||
# Cursor not yet advanced — same entries are still available
|
||||
assert store.build_dream_prompt() is not None
|
||||
|
||||
@pytest.fixture
|
||||
def dream(store, mock_provider, mock_runner):
|
||||
d = Dream(store=store, provider=mock_provider, model="test-model", max_batch_size=5)
|
||||
d._runner = mock_runner
|
||||
return d
|
||||
# Advance cursor
|
||||
store.set_last_dream_cursor(c1)
|
||||
# Now no new entries
|
||||
assert store.build_dream_prompt() is None
|
||||
|
||||
# Add new entry
|
||||
store.append_history("second")
|
||||
r2 = store.build_dream_prompt()
|
||||
assert r2 is not None
|
||||
_, c2 = r2
|
||||
assert c2 > c1
|
||||
|
||||
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 [],
|
||||
)
|
||||
def test_prompt_includes_skill_creator_path(self, store):
|
||||
store.append_history("test")
|
||||
result = store.build_dream_prompt()
|
||||
assert result is not None
|
||||
prompt, _ = result
|
||||
assert "skill-creator" in prompt
|
||||
|
||||
def test_truncates_long_entries(self, store):
|
||||
long_content = "x" * 2000
|
||||
store.append_history(long_content)
|
||||
result = store.build_dream_prompt()
|
||||
assert result is not None
|
||||
prompt, _ = result
|
||||
# The full 2000 chars should not appear — truncated to 500
|
||||
assert long_content not in prompt
|
||||
assert "x" * 500 in prompt
|
||||
|
||||
class TestDreamRun:
|
||||
async def test_noop_when_no_unprocessed_history(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should not call LLM when there's nothing to process."""
|
||||
result = await dream.run()
|
||||
assert result is False
|
||||
mock_provider.chat_with_retry.assert_not_called()
|
||||
mock_runner.run.assert_not_called()
|
||||
def test_batches_oldest_unprocessed_entries_first(self, store):
|
||||
for i in range(25):
|
||||
store.append_history(f"entry-{i + 1:02d}")
|
||||
|
||||
async def test_calls_runner_for_unprocessed_entries(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should call AgentRunner when there are unprocessed history entries."""
|
||||
store.append_history("User prefers dark mode")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="New fact")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result(
|
||||
tool_events=[{"name": "edit_file", "status": "ok", "detail": "memory/MEMORY.md"}],
|
||||
))
|
||||
result = await dream.run()
|
||||
assert result is True
|
||||
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
|
||||
result = store.build_dream_prompt(max_entries=20)
|
||||
assert result is not None
|
||||
prompt, cursor = result
|
||||
|
||||
async def test_advances_dream_cursor(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should advance the cursor after processing."""
|
||||
store.append_history("event 1")
|
||||
store.append_history("event 2")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
await dream.run()
|
||||
assert store.get_last_dream_cursor() == 2
|
||||
assert cursor == 20
|
||||
assert "entry-01" in prompt
|
||||
assert "entry-20" in prompt
|
||||
assert "entry-21" not in prompt
|
||||
|
||||
async def test_compacts_processed_history(self, dream, mock_provider, 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_provider.chat_with_retry.return_value = MagicMock(content="Nothing new")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
await dream.run()
|
||||
# After Dream, cursor is advanced and 3, compact keeps last max_history_entries
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert all(e["cursor"] > 0 for e in entries)
|
||||
store.set_last_dream_cursor(cursor)
|
||||
next_result = store.build_dream_prompt(max_entries=20)
|
||||
assert next_result is not None
|
||||
next_prompt, next_cursor = next_result
|
||||
assert next_cursor == 25
|
||||
assert "entry-21" in next_prompt
|
||||
assert "entry-25" in next_prompt
|
||||
|
||||
async def test_skill_phase_uses_builtin_skill_creator_path(self, dream, mock_provider, mock_runner, store):
|
||||
"""Dream should point skill creation guidance at the builtin skill-creator template."""
|
||||
store.append_history("Repeated workflow one")
|
||||
store.append_history("Repeated workflow two")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKILL] test-skill: test description")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
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_skill_write_tool_accepts_workspace_relative_skill_path(self, dream, store):
|
||||
"""Dream skill creation should allow skills/<name>/SKILL.md relative to workspace root."""
|
||||
write_tool = dream._tools.get("write_file")
|
||||
assert write_tool is not None
|
||||
|
||||
result = await write_tool.execute(
|
||||
path="skills/test-skill/SKILL.md",
|
||||
content="---\nname: test-skill\ndescription: Test\n---\n",
|
||||
def test_dream_prompt_consumes_consolidator_attribute_tags(self):
|
||||
prompt = render_template(
|
||||
"agent/dream.md",
|
||||
strip=True,
|
||||
skill_creator_path="skills/skill-creator/SKILL.md",
|
||||
)
|
||||
|
||||
assert "Successfully wrote" in result
|
||||
assert (store.workspace / "skills" / "test-skill" / "SKILL.md").exists()
|
||||
assert "History attribute tags" in prompt
|
||||
assert "[skip]: audit-only" in prompt
|
||||
assert "[correction]: replace the older conflicting fact" in prompt
|
||||
assert "Always strip these bracketed tags from saved memory content" in prompt
|
||||
|
||||
async def test_phase1_prompt_includes_line_age_annotations(self, dream, mock_provider, mock_runner, store):
|
||||
"""Phase 1 prompt should have per-line age suffixes in MEMORY.md when git is available."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
# Init git so line_ages works
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial memory state")
|
||||
class TestDreamTools:
|
||||
def test_dream_tools_are_restricted_to_file_edits(self, store):
|
||||
tools = store.build_dream_tools()
|
||||
|
||||
await dream.run()
|
||||
assert set(tools.tool_names) == {
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"read_file",
|
||||
"write_file",
|
||||
}
|
||||
|
||||
# The MEMORY.md section should not crash and should contain the memory content
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
assert "## Current MEMORY.md" in user_msg
|
||||
|
||||
async def test_phase1_annotates_only_memory_not_soul_or_user(self, dream, mock_provider, mock_runner, store):
|
||||
"""SOUL.md and USER.md should never have age annotations — they are permanent."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
class TestEphemeralDirect:
|
||||
"""Tests for the ephemeral flag that skips history.jsonl writes for Dream."""
|
||||
|
||||
@pytest.fixture
|
||||
def _make_loop(self, tmp_path):
|
||||
"""Factory fixture that builds a minimal AgentLoop with mocked deps."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
store.write_soul("# Soul")
|
||||
store.write_memory("# Memory")
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
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={},
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.loop.SessionManager"),
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub,
|
||||
patch("nanobot.agent.loop.Consolidator") as mock_consolidator_cls,
|
||||
):
|
||||
mock_sub.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
mock_consolidator_cls.return_value.maybe_consolidate_by_tokens = AsyncMock()
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=8000,
|
||||
)
|
||||
|
||||
return loop, store
|
||||
|
||||
async def test_ephemeral_skips_raw_archive(self, tmp_path, _make_loop):
|
||||
"""When ephemeral=True, raw_archive must not be called."""
|
||||
from unittest.mock import patch
|
||||
|
||||
loop, store = _make_loop
|
||||
|
||||
with patch.object(loop.context.memory, "raw_archive") as mock_archive:
|
||||
await loop.process_direct(
|
||||
"test", session_key="dream:test", ephemeral=True,
|
||||
)
|
||||
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."""
|
||||
loop, store = _make_loop
|
||||
await loop.process_direct("test", session_key="cli:normal")
|
||||
|
||||
async def test_ephemeral_sets_ctx_flag(self, tmp_path, _make_loop):
|
||||
"""Verify that ephemeral=True is forwarded to TurnContext."""
|
||||
from unittest.mock import patch
|
||||
|
||||
loop, store = _make_loop
|
||||
|
||||
captured = {}
|
||||
|
||||
original_save = loop._state_save
|
||||
|
||||
async def patched_save(ctx):
|
||||
captured["ephemeral"] = ctx.ephemeral
|
||||
return await original_save(ctx)
|
||||
|
||||
with patch.object(loop, "_state_save", side_effect=patched_save):
|
||||
await loop.process_direct(
|
||||
"test", session_key="dream:check", ephemeral=True,
|
||||
)
|
||||
|
||||
assert captured.get("ephemeral") is True
|
||||
|
||||
async def test_default_ephemeral_is_false(self, tmp_path, _make_loop):
|
||||
"""By default ephemeral is False in TurnContext."""
|
||||
from unittest.mock import patch
|
||||
|
||||
loop, store = _make_loop
|
||||
|
||||
captured = {}
|
||||
|
||||
original_save = loop._state_save
|
||||
|
||||
async def patched_save(ctx):
|
||||
captured["ephemeral"] = ctx.ephemeral
|
||||
return await original_save(ctx)
|
||||
|
||||
with patch.object(loop, "_state_save", side_effect=patched_save):
|
||||
await loop.process_direct("test", session_key="cli:normal")
|
||||
|
||||
assert captured.get("ephemeral") is False
|
||||
|
||||
async def test_ephemeral_skips_consolidator(self, tmp_path, _make_loop):
|
||||
"""When ephemeral=True, consolidator.maybe_consolidate_by_tokens is not called."""
|
||||
from unittest.mock import patch
|
||||
|
||||
loop, store = _make_loop
|
||||
|
||||
with patch.object(
|
||||
loop.consolidator, "maybe_consolidate_by_tokens",
|
||||
) as mock_consolidate:
|
||||
await loop.process_direct(
|
||||
"test", session_key="dream:consolidate-test", ephemeral=True,
|
||||
)
|
||||
mock_consolidate.assert_not_called()
|
||||
|
||||
async def test_ephemeral_response_reports_stop_reason(self, tmp_path, _make_loop):
|
||||
loop, store = _make_loop
|
||||
loop.provider.chat_with_retry.return_value = LLMResponse(
|
||||
content="provider error",
|
||||
finish_reason="error",
|
||||
)
|
||||
|
||||
resp = await loop.process_direct(
|
||||
"test", session_key="dream:error", ephemeral=True,
|
||||
)
|
||||
|
||||
assert resp is not None
|
||||
assert resp.metadata["_stop_reason"] == "error"
|
||||
assert MemoryStore.dream_run_completed(resp) is False
|
||||
|
||||
async def test_dream_turn_can_skip_unbatched_recent_history(self, tmp_path):
|
||||
"""Dream must only see the batch selected by build_dream_prompt."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
for i in range(60):
|
||||
store.append_history(f"entry-{i + 1:02d}")
|
||||
|
||||
result = store.build_dream_prompt(max_entries=20)
|
||||
assert result is not None
|
||||
prompt, cursor = result
|
||||
assert cursor == 20
|
||||
|
||||
captured: dict[str, list[dict]] = {}
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.supports_tools = True
|
||||
provider.generation = MagicMock(max_tokens=4096)
|
||||
|
||||
async def chat_with_retry(**kwargs):
|
||||
captured["messages"] = kwargs["messages"]
|
||||
return LLMResponse(content="done", finish_reason="stop")
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
loop = AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=8000,
|
||||
)
|
||||
|
||||
await loop.process_direct(
|
||||
prompt,
|
||||
session_key="dream:test",
|
||||
ephemeral=True,
|
||||
tools=store.build_dream_tools(),
|
||||
)
|
||||
|
||||
messages = captured["messages"]
|
||||
system_prompt = messages[0]["content"]
|
||||
request_text = "\n".join(str(message.get("content", "")) for message in messages)
|
||||
assert "# Recent History" not in system_prompt
|
||||
assert "entry-01" in request_text
|
||||
assert "entry-20" in request_text
|
||||
assert "entry-21" not in request_text
|
||||
assert "entry-60" not in request_text
|
||||
|
||||
|
||||
class TestEphemeralHooks:
|
||||
"""When ephemeral=True, extra hooks must not fire."""
|
||||
|
||||
@pytest.fixture
|
||||
def _make_loop_with_spy(self, tmp_path):
|
||||
"""Build an AgentLoop with a spy hook to verify hook firing behavior."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from nanobot.agent.hook import AgentHook
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
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={},
|
||||
)
|
||||
)
|
||||
|
||||
spy = MagicMock(spec=AgentHook)
|
||||
spy.wants_streaming.return_value = False
|
||||
spy.before_iteration = AsyncMock()
|
||||
spy.after_iteration = AsyncMock()
|
||||
|
||||
with (
|
||||
patch("nanobot.agent.loop.SessionManager"),
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub,
|
||||
patch("nanobot.agent.loop.Consolidator") as mock_consolidator_cls,
|
||||
):
|
||||
mock_sub.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
mock_consolidator_cls.return_value.maybe_consolidate_by_tokens = AsyncMock()
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
context_window_tokens=8000,
|
||||
hooks=[spy],
|
||||
)
|
||||
|
||||
return loop, spy
|
||||
|
||||
async def test_extra_hooks_skipped_when_ephemeral(self, tmp_path, _make_loop_with_spy):
|
||||
"""When ephemeral=True, extra hooks must not fire."""
|
||||
loop, spy = _make_loop_with_spy
|
||||
|
||||
await loop.process_direct(
|
||||
"test", session_key="dream:hook-test", ephemeral=True,
|
||||
)
|
||||
spy.before_iteration.assert_not_called()
|
||||
spy.after_iteration.assert_not_called()
|
||||
|
||||
async def test_extra_hooks_fire_for_normal_sessions(self, tmp_path, _make_loop_with_spy):
|
||||
"""Without ephemeral, extra hooks should fire normally."""
|
||||
loop, spy = _make_loop_with_spy
|
||||
|
||||
await loop.process_direct("test", session_key="cli:normal")
|
||||
spy.before_iteration.assert_called()
|
||||
|
||||
|
||||
class TestDreamCommitMessage:
|
||||
async def test_commit_includes_response_summary(self, tmp_path):
|
||||
"""Git auto-commit after Dream should include the LLM response in the body."""
|
||||
import subprocess
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
|
||||
store = MemoryStore(tmp_path)
|
||||
store.write_soul("# Soul")
|
||||
store.write_memory("# Memory")
|
||||
store.append_history("user discussed project goals")
|
||||
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.supports_tools = True
|
||||
provider.generation = MagicMock(max_tokens=4096)
|
||||
provider.chat_with_retry = AsyncMock(return_value=MagicMock(
|
||||
content="Identified 2 new facts about project goals",
|
||||
finish_reason="stop",
|
||||
tool_calls=[],
|
||||
usage={},
|
||||
))
|
||||
|
||||
store.git.init()
|
||||
store.git.auto_commit("initial state")
|
||||
|
||||
await dream.run()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
# The ← suffix should only appear in MEMORY.md section
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
soul_section = user_msg.split("## Current SOUL.md")[1].split("## Current USER.md")[0]
|
||||
user_section = user_msg.split("## Current USER.md")[1]
|
||||
# SOUL and USER should not contain age arrows
|
||||
assert "\u2190" not in soul_section
|
||||
assert "\u2190" not in user_section
|
||||
|
||||
async def test_phase1_prompt_works_without_git(self, dream, mock_provider, mock_runner, store):
|
||||
"""Phase 1 should work fine even if git is not initialized (no age annotations)."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
# Should still succeed — just without age annotations
|
||||
mock_provider.chat_with_retry.assert_called_once()
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
assert "## Current MEMORY.md" in user_msg
|
||||
|
||||
async def test_phase1_prompt_carries_age_suffix_for_stale_lines(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""End-to-end: ages >14d must appear verbatim in the LLM prompt, ages ≤14d must not."""
|
||||
# MEMORY.md fixture has 2 non-blank lines ("# Memory" and "- Project X active").
|
||||
# Inject four ages to cover threshold boundaries: >14 suffix, ==14 no suffix, <14 no suffix.
|
||||
store.write_memory("# Memory\n- Project X active\n- fresh item\n- edge case line")
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
fake_ages = [
|
||||
LineAge(age_days=30), # "# Memory" → should get ← 30d
|
||||
LineAge(age_days=20), # "- Project X..." → should get ← 20d
|
||||
LineAge(age_days=14), # "- fresh item" → ==14, threshold is strictly >14, no suffix
|
||||
LineAge(age_days=5), # "- edge case..." → no suffix
|
||||
]
|
||||
with patch.object(store.git, "line_ages", return_value=fake_ages):
|
||||
await dream.run()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
assert "\u2190 30d" in memory_section
|
||||
assert "\u2190 20d" in memory_section
|
||||
assert "\u2190 14d" not in memory_section
|
||||
assert "\u2190 5d" not in memory_section
|
||||
|
||||
async def test_phase1_skips_annotation_when_disabled(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""`annotate_line_ages=False` must bypass the git lookup entirely and keep MEMORY.md raw."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
dream.annotate_line_ages = False
|
||||
# line_ages must be bypassed entirely — verify with a spy rather than a
|
||||
# raising side_effect, because _annotate_with_ages catches Exception
|
||||
# (which swallows AssertionError) and would hide an accidental call.
|
||||
with patch.object(store.git, "line_ages") as mock_line_ages:
|
||||
await dream.run()
|
||||
mock_line_ages.assert_not_called()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
assert "\u2190" not in user_msg
|
||||
|
||||
async def test_phase1_skips_annotation_on_line_ages_length_mismatch(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""If ages length != lines length (dirty working tree), skip annotation instead of mis-tagging."""
|
||||
# MEMORY.md has 2 non-blank lines but we hand back only 1 age → mismatch.
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
with patch.object(store.git, "line_ages", return_value=[LineAge(age_days=999)]):
|
||||
await dream.run()
|
||||
|
||||
call_args = mock_provider.chat_with_retry.call_args
|
||||
user_msg = call_args.kwargs.get("messages", call_args[1].get("messages"))[1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
# No age arrow at all — we refused to annotate rather than tag the wrong line.
|
||||
assert "\u2190" not in memory_section
|
||||
|
||||
async def test_phase1_prompt_uses_threshold_from_template_var(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""System prompt should reference the stale-threshold constant, not a hardcoded 14."""
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
system_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][0]["content"]
|
||||
# The template renders with stale_threshold_days=14 → LLM must see "N>14"
|
||||
assert "N>14" in system_msg
|
||||
|
||||
|
||||
class TestDreamPromptCaps:
|
||||
"""Dream's Phase 1/2 prompt must not be poisoned by a legacy oversized
|
||||
history entry or a runaway MEMORY.md. Without caps, a single pre-#3412
|
||||
raw_archive dump in history.jsonl would make every subsequent Dream run
|
||||
exceed the context window and silently advance the cursor past real work.
|
||||
"""
|
||||
|
||||
async def test_phase1_caps_huge_memory_file(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""A MEMORY.md much larger than _MEMORY_FILE_MAX_CHARS must be truncated
|
||||
in the prompt preview (full content is still reachable via read_file)."""
|
||||
store.write_memory("M" * (dream._MEMORY_FILE_MAX_CHARS * 5))
|
||||
store.append_history("some event")
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
memory_section = user_msg.split("## Current MEMORY.md")[1].split("## Current SOUL.md")[0]
|
||||
assert len(memory_section) < dream._MEMORY_FILE_MAX_CHARS + 500
|
||||
|
||||
async def test_phase1_caps_huge_history_entry(
|
||||
self, dream, mock_provider, mock_runner, store,
|
||||
):
|
||||
"""A legacy oversized history entry (e.g. pre-#3412 raw_archive dump)
|
||||
must not explode the Phase 1 prompt — each entry is capped in the
|
||||
preview, even though the JSONL record itself stays full-size."""
|
||||
# Bypass the append_history cap by writing directly, simulating a
|
||||
# record that was written by an older nanobot build before any caps.
|
||||
store.history_file.write_text(
|
||||
json.dumps({
|
||||
"cursor": 1,
|
||||
"timestamp": "2026-04-01 10:00",
|
||||
"content": "H" * (dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS * 8),
|
||||
}) + "\n",
|
||||
encoding="utf-8",
|
||||
# Simulate what the cron handler does: produce a resp with content,
|
||||
# build the commit message via the actual function, then commit.
|
||||
resp_content = "Identified 2 new facts about project goals"
|
||||
resp = MagicMock(content=resp_content)
|
||||
msg = MemoryStore.build_dream_commit_message(
|
||||
"dream: periodic memory consolidation", resp,
|
||||
)
|
||||
mock_provider.chat_with_retry.return_value = MagicMock(content="[SKIP]")
|
||||
mock_runner.run = AsyncMock(return_value=_make_run_result())
|
||||
|
||||
await dream.run()
|
||||
|
||||
user_msg = mock_provider.chat_with_retry.call_args.kwargs["messages"][1]["content"]
|
||||
history_section = user_msg.split("## Conversation History\n")[1].split("\n\n## Current Date")[0]
|
||||
assert len(history_section) < dream._HISTORY_ENTRY_PREVIEW_MAX_CHARS + 500
|
||||
# Write a change so auto_commit has something to commit
|
||||
store.write_memory("# Memory\n- Updated by Dream")
|
||||
sha = store.git.auto_commit(msg)
|
||||
assert sha is not None
|
||||
|
||||
log = subprocess.check_output(
|
||||
["git", "log", "-1", "--format=%B"],
|
||||
cwd=str(tmp_path), text=True,
|
||||
).strip()
|
||||
assert "dream: periodic memory consolidation" in log
|
||||
assert "Identified 2 new facts" in log
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for Dream session key generation and rotation."""
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from nanobot.agent.memory import MemoryStore
|
||||
|
||||
|
||||
class TestDreamSessionKey:
|
||||
def test_contains_timestamp(self):
|
||||
key = MemoryStore.dream_session_key()
|
||||
assert key.startswith("dream:")
|
||||
ts_part = key.split(":", 1)[1]
|
||||
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()
|
||||
assert k1 != k2
|
||||
|
||||
|
||||
class TestPruneDreamSessions:
|
||||
def test_keeps_n_most_recent(self, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
|
||||
for i in range(15):
|
||||
key = f"dream:20260528-{100000 + i:06d}"
|
||||
safe_key = key.replace(":", "_")
|
||||
path = sessions_dir / f"{safe_key}.jsonl"
|
||||
path.write_text(
|
||||
f'{{"_type": "metadata", "key": "{key}", '
|
||||
f'"created_at": "2026-05-28T10:00:{i:02d}", '
|
||||
f'"updated_at": "2026-05-28T10:00:{i:02d}"}}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
normal_path = sessions_dir / "telegram_123.jsonl"
|
||||
normal_path.write_text('{"_type": "metadata"}\n', encoding="utf-8")
|
||||
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
|
||||
dream_files = sorted(sessions_dir.glob("dream_*.jsonl"))
|
||||
assert len(dream_files) == 10
|
||||
remaining_keys = [f.stem for f in dream_files]
|
||||
assert "dream_20260528-100000" not in remaining_keys
|
||||
assert "dream_20260528-100014" in remaining_keys
|
||||
assert normal_path.exists()
|
||||
|
||||
def test_noop_when_under_limit(self, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
for i in range(3):
|
||||
key = f"dream:20260528-{100000 + i:06d}"
|
||||
safe_key = key.replace(":", "_")
|
||||
(sessions_dir / f"{safe_key}.jsonl").write_text("{}", encoding="utf-8")
|
||||
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
assert len(list(sessions_dir.glob("dream_*.jsonl"))) == 3
|
||||
|
||||
def test_empty_dir_noop(self, tmp_path):
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
MemoryStore.prune_dream_sessions(sessions_dir, keep=10)
|
||||
@@ -299,8 +299,7 @@ def _make_loop(tmp_path, hooks=None):
|
||||
with patch("nanobot.agent.loop.ContextBuilder"), \
|
||||
patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as mock_sub_mgr, \
|
||||
patch("nanobot.agent.loop.Consolidator"), \
|
||||
patch("nanobot.agent.loop.Dream"):
|
||||
patch("nanobot.agent.loop.Consolidator"):
|
||||
mock_sub_mgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(
|
||||
bus=bus, provider=provider, workspace=tmp_path, hooks=hooks,
|
||||
|
||||
@@ -47,9 +47,6 @@ def test_provider_refresh_updates_all_model_dependents(tmp_path: Path) -> None:
|
||||
assert loop.consolidator.model == "new-model"
|
||||
assert loop.consolidator.context_window_tokens == 2000
|
||||
assert loop.consolidator.max_completion_tokens == 456
|
||||
assert loop.dream.provider is new_provider
|
||||
assert loop.dream.model == "new-model"
|
||||
assert loop.dream._runner.provider is new_provider
|
||||
|
||||
|
||||
def test_llm_runtime_refreshes_provider_snapshot(tmp_path: Path) -> None:
|
||||
|
||||
@@ -61,7 +61,6 @@ def test_model_preset_setter_updates_state(tmp_path) -> None:
|
||||
assert loop.consolidator.model == "openai/gpt-4.1"
|
||||
assert loop.consolidator.context_window_tokens == 32_768
|
||||
assert loop.consolidator.max_completion_tokens == 4096
|
||||
assert loop.dream.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
def test_model_preset_setter_calls_runtime_model_publisher(tmp_path) -> None:
|
||||
@@ -112,8 +111,6 @@ def test_model_preset_setter_replaces_provider_from_snapshot(tmp_path) -> None:
|
||||
assert loop.subagents.provider is new_provider
|
||||
assert loop.subagents.runner.provider is new_provider
|
||||
assert loop.consolidator.provider is new_provider
|
||||
assert loop.dream.provider is new_provider
|
||||
assert loop.dream._runner.provider is new_provider
|
||||
assert loop.model == "anthropic/claude-opus-4-5"
|
||||
assert loop.context_window_tokens == 200_000
|
||||
assert loop.consolidator.max_completion_tokens == 2048
|
||||
@@ -140,7 +137,6 @@ def test_model_preset_setter_failure_leaves_old_state(tmp_path) -> None:
|
||||
assert loop.model == "base-model"
|
||||
assert loop.subagents.model == "base-model"
|
||||
assert loop.consolidator.model == "base-model"
|
||||
assert loop.dream.model == "base-model"
|
||||
assert loop.context_window_tokens == 1000
|
||||
assert loop.consolidator.max_completion_tokens == 123
|
||||
|
||||
|
||||
@@ -39,8 +39,7 @@ def _make_loop(tmp_path: Path, unified_session: bool = False) -> AgentLoop:
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
|
||||
with patch("nanobot.agent.loop.SessionManager"), \
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr, \
|
||||
patch("nanobot.agent.loop.Dream"):
|
||||
patch("nanobot.agent.loop.SubagentManager") as MockSubMgr:
|
||||
MockSubMgr.return_value.cancel_by_session = AsyncMock(return_value=0)
|
||||
loop = AgentLoop(
|
||||
bus=bus,
|
||||
|
||||
@@ -1607,14 +1607,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
config.gateway.port = 18791
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class _FakeDream:
|
||||
model = None
|
||||
max_batch_size = 0
|
||||
max_iterations = 0
|
||||
|
||||
async def run(self) -> None:
|
||||
return None
|
||||
|
||||
class _FakeSessionManager:
|
||||
def flush_all(self) -> int:
|
||||
return 0
|
||||
@@ -1626,7 +1618,6 @@ def test_gateway_health_endpoint_binds_and_serves_expected_responses(
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
self.model = "test-model"
|
||||
self.provider = object()
|
||||
self.dream = _FakeDream()
|
||||
self.sessions = _FakeSessionManager()
|
||||
|
||||
def llm_runtime(self) -> None:
|
||||
|
||||
@@ -87,7 +87,6 @@ async def test_model_command_switches_preset(tmp_path) -> None:
|
||||
assert loop.model == "openai/gpt-4.1"
|
||||
assert loop.subagents.model == "openai/gpt-4.1"
|
||||
assert loop.consolidator.model == "openai/gpt-4.1"
|
||||
assert loop.dream.model == "openai/gpt-4.1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -82,38 +82,37 @@ class TestResolveConfig:
|
||||
assert saved["channels"]["telegram"]["token"] == "${MY_TOKEN}"
|
||||
|
||||
def test_preserves_excluded_fields_when_no_env_refs(self, tmp_path):
|
||||
"""Regression: fields with ``exclude=True`` (e.g. DreamConfig.cron)
|
||||
"""Regression: fields with ``exclude=True`` (e.g. ProviderConfig.openai_codex)
|
||||
must survive ``resolve_config_env_vars`` when the config has no
|
||||
``${VAR}`` references. Previously the unconditional dump→revalidate
|
||||
roundtrip silently dropped them."""
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{"agents": {"defaults": {"dream": {"cron": "5 11 * * *"}}}}
|
||||
{"providers": {"openaiCodex": {"apiKey": "secret"}}}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
raw = load_config(config_path)
|
||||
assert raw.agents.defaults.dream.cron == "5 11 * * *"
|
||||
assert raw.providers.openai_codex.api_key == "secret"
|
||||
|
||||
resolved = resolve_config_env_vars(raw)
|
||||
assert resolved.agents.defaults.dream.cron == "5 11 * * *"
|
||||
assert resolved.agents.defaults.dream.describe_schedule() == (
|
||||
"cron 5 11 * * * (legacy)"
|
||||
)
|
||||
assert resolved.providers.openai_codex.api_key == "secret"
|
||||
|
||||
def test_preserves_excluded_fields_with_env_refs(self, tmp_path, monkeypatch):
|
||||
"""Excluded fields must also survive when the config contains
|
||||
``${VAR}`` refs elsewhere. An in-place walk preserves the legacy
|
||||
``cron`` override even as unrelated string fields are substituted."""
|
||||
``${VAR}`` refs elsewhere. An in-place walk preserves the excluded
|
||||
field even as unrelated string fields are substituted."""
|
||||
monkeypatch.setenv("TEST_API_KEY", "resolved-key")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"dream": {"cron": "5 11 * * *"}}},
|
||||
"providers": {"groq": {"apiKey": "${TEST_API_KEY}"}},
|
||||
"providers": {
|
||||
"openaiCodex": {"apiKey": "secret"},
|
||||
"groq": {"apiKey": "${TEST_API_KEY}"},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
@@ -123,7 +122,4 @@ class TestResolveConfig:
|
||||
resolved = resolve_config_env_vars(raw)
|
||||
|
||||
assert resolved.providers.groq.api_key == "resolved-key"
|
||||
assert resolved.agents.defaults.dream.cron == "5 11 * * *"
|
||||
assert resolved.agents.defaults.dream.describe_schedule() == (
|
||||
"cron 5 11 * * * (legacy)"
|
||||
)
|
||||
assert resolved.providers.openai_codex.api_key == "secret"
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_process_direct_accepts_media() -> None:
|
||||
|
||||
captured_msg = None
|
||||
|
||||
async def fake_process(msg, *, session_key="", on_progress=None, on_stream=None, on_stream_end=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
|
||||
|
||||
Reference in New Issue
Block a user