diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 25a230d6..e23e0422 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -181,6 +181,20 @@ def _advance_dream_cursor_if_behind(memory: Any) -> None: memory.set_last_dream_cursor(latest) +def _commit_dream_changes(memory: Any) -> str | None: + """Commit durable Dream edits, without entering the commit path for a no-op run.""" + if not memory.git.is_initialized(): + return None + diff_body = memory.dream_content_diff() + if not diff_body: + return None + message = memory.build_dream_commit_message( + "dream: periodic memory consolidation", + diff_body, + ) + return memory.git.auto_commit(message) + + class SafeFileHistory(FileHistory): """FileHistory subclass that sanitizes surrogate characters on write. @@ -1508,7 +1522,6 @@ def _run_gateway( from nanobot.agent.memory import MemoryStore dream_session_key = MemoryStore.dream_session_key - build_dream_commit_message = MemoryStore.build_dream_commit_message prune_dream_sessions = MemoryStore.prune_dream_sessions store = agent.context.memory @@ -1537,13 +1550,6 @@ def _run_gateway( if productive: store.set_last_dream_cursor(last_cursor) logger.info("Dream cron job completed, cursor advanced to {}", last_cursor) - if store.git.is_initialized(): - msg = build_dream_commit_message( - "dream: periodic memory consolidation", diff_body, - ) - sha = store.git.auto_commit(msg) - if sha: - logger.info("Dream commit: {}", sha) elif MemoryStore.dream_run_completed(resp): logger.info( "Dream cron job completed with no memory changes; " @@ -1564,6 +1570,9 @@ def _run_gateway( source="dream", timezone_name=config.agents.defaults.timezone, ) + sha = _commit_dream_changes(store) + if sha: + logger.info("Dream commit: {}", sha) store.compact_history() prune_dream_sessions(agent.sessions.sessions_dir) return None diff --git a/tests/agent/test_dream.py b/tests/agent/test_dream.py index 04d355b7..7b3f41e5 100644 --- a/tests/agent/test_dream.py +++ b/tests/agent/test_dream.py @@ -654,53 +654,6 @@ class TestDreamCommitMessage: == "dream: x\n\nSOUL.md: +1 -0" ) - def test_productive_gate_skips_commit_when_no_content_change(self, tmp_path): - """When no content files changed, there should be no auto_commit. - In the old code, auto_commit ran unconditionally - in the finally block; now it only runs when productive is True.""" - from unittest.mock import MagicMock - - store = MemoryStore(tmp_path) - store.write_soul("# Soul") - store.write_memory("# Memory") - store.git.init() - store.git.auto_commit("initial") - - assert store.dream_content_diff() == "" - - original = store.git.auto_commit - store.git.auto_commit = MagicMock(wraps=original) - - # Exact productive gate pattern from the cron handler (commands.py): - diff_body = store.dream_content_diff() - productive = bool(diff_body) # git is initialized - if productive and store.git.is_initialized(): - store.git.auto_commit("dream: periodic memory consolidation") - - store.git.auto_commit.assert_not_called() - - def test_productive_gate_allows_commit_on_content_change(self, tmp_path): - """The productive gate must perform auto_commit when content files changed.""" - from unittest.mock import MagicMock - - store = MemoryStore(tmp_path) - store.write_soul("# Soul") - store.write_memory("# Memory") - store.git.init() - store.git.auto_commit("initial") - - store.write_memory("# Memory\n- Research notes") - - original = store.git.auto_commit - store.git.auto_commit = MagicMock(wraps=original) - - diff_body = store.dream_content_diff() - productive = bool(diff_body) - if productive and store.git.is_initialized(): - store.git.auto_commit("dream: periodic memory consolidation") - - store.git.auto_commit.assert_called_once() - class TestDreamContentDiff: """The ground-truth signal that gates cursor advance and commit messages.""" diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 7d6232ef..3d6fbbe5 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -155,6 +155,36 @@ def test_disabled_dream_cursor_only_advances_when_behind(tmp_path) -> None: assert store.get_last_dream_cursor() == 10 +def test_commit_dream_changes_skips_noop_run(tmp_path) -> None: + store = MemoryStore(tmp_path) + store.write_soul("# Soul") + store.write_memory("# Memory") + store.git.init() + store.git.auto_commit("initial") + store.git.auto_commit = MagicMock(wraps=store.git.auto_commit) + + assert cli_commands._commit_dream_changes(store) is None + store.git.auto_commit.assert_not_called() + + +def test_commit_dream_changes_commits_real_edits(tmp_path) -> None: + store = MemoryStore(tmp_path) + store.write_soul("# Soul") + store.write_memory("# Memory") + store.git.init() + store.git.auto_commit("initial") + store.write_memory("# Memory\n- Research notes") + store.git.auto_commit = MagicMock(wraps=store.git.auto_commit) + + sha = cli_commands._commit_dream_changes(store) + + assert sha is not None + store.git.auto_commit.assert_called_once() + message = store.git.auto_commit.call_args.args[0] + assert message.startswith("dream: periodic memory consolidation\n\n") + assert "Research notes" in message + + @pytest.fixture def mock_paths(): """Mock config/workspace paths for test isolation."""