Files
hkuds--nanobot/tests/command/test_builtin_dream.py
T
wehub-resource-sync ba1d0b91a4
Test Suite / webui (push) Failing after 1s
Test Suite / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:06:36 +08:00

401 lines
14 KiB
Python

from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
from nanobot.agent.memory import MemoryStore
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.command.builtin import (
build_help_text,
builtin_command_palette,
cmd_dream,
cmd_dream_log,
cmd_dream_prompt,
cmd_dream_restore,
)
from nanobot.command.router import CommandContext
from nanobot.utils.gitstore import CommitInfo
class _FakeStore:
def __init__(
self,
git,
last_dream_cursor: int = 1,
dream_prompt_result=None,
content_diff: str = "",
):
self.git = git
self._last_dream_cursor = last_dream_cursor
self._dream_prompt_result = dream_prompt_result
self._content_diff = content_diff
self.compact_history_called = False
def get_last_dream_cursor(self) -> int:
return self._last_dream_cursor
def build_dream_prompt(self):
return self._dream_prompt_result
def build_dream_tools(self):
return None
def set_last_dream_cursor(self, value: int) -> None:
self._last_dream_cursor = value
def dream_content_diff(self) -> str:
return self._content_diff
def compact_history(self) -> None:
self.compact_history_called = True
class _FakeGit:
def __init__(
self,
*,
initialized: bool = True,
commits: list[CommitInfo] | None = None,
diff_map: dict[str, tuple[CommitInfo, str] | None] | None = None,
revert_result: str | None = None,
):
self._initialized = initialized
self._commits = commits or []
self._diff_map = diff_map or {}
self._revert_result = revert_result
def is_initialized(self) -> bool:
return self._initialized
def log(self, max_entries: int = 20) -> list[CommitInfo]:
return self._commits[:max_entries]
def show_commit_diff(self, sha: str, max_entries: int = 20):
return self._diff_map.get(sha)
def revert(self, sha: str) -> str | None:
return self._revert_result
def auto_commit(self, message: str) -> str | None:
return None
class _FakeBus:
def __init__(self):
self.outbound = []
async def publish_outbound(self, message):
self.outbound.append(message)
def _make_ctx(raw: str, git: _FakeGit, *, args: str = "", last_dream_cursor: int = 1) -> CommandContext:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
store = _FakeStore(git, last_dream_cursor=last_dream_cursor)
loop = SimpleNamespace(consolidator=SimpleNamespace(store=store))
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
def _make_dream_ctx(tmp_path) -> tuple[CommandContext, _FakeBus]:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=None)
bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
return ctx, bus
def _make_dream_prompt_ctx(tmp_path, raw: str = "/dream-prompt", args: str = "") -> CommandContext:
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content=raw)
loop = SimpleNamespace(context=SimpleNamespace(memory=MemoryStore(tmp_path)))
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
@pytest.mark.asyncio
async def test_dream_no_history_explains_how_to_create_input(tmp_path) -> None:
ctx, bus = _make_dream_ctx(tmp_path)
immediate = await cmd_dream(ctx)
await asyncio.sleep(0)
assert immediate.content == "Dreaming..."
assert len(bus.outbound) == 1
content = bus.outbound[0].content
assert "Dream has no conversation history to process yet." in content
assert "`memory/history.jsonl`" in content
assert "idle auto-compact" in content
assert "Dream cursor" in content
assert "agents.defaults.idleCompactAfterMinutes" in content
assert "/dream-prompt" in content
@pytest.mark.asyncio
async def test_dream_internal_run_silences_progress(tmp_path) -> None:
msg = InboundMessage(channel="feishu", sender_id="u1", chat_id="chat1", content="/dream")
store = _FakeStore(_FakeGit(initialized=False), dream_prompt_result=("dream prompt", 123))
bus = _FakeBus()
calls = []
async def process_direct(*args, **kwargs):
calls.append((args, kwargs))
return OutboundMessage(
channel="cli",
chat_id="direct",
content="done",
metadata={"_stop_reason": "completed"},
)
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
await cmd_dream(ctx)
await asyncio.sleep(0)
assert len(calls) == 1
assert callable(calls[0][1]["on_progress"])
def _build_runnable_dream(
tmp_path,
*,
initialized: bool,
content_diff: str,
stop_reason: str = "completed",
) -> tuple[CommandContext, _FakeStore]:
"""Build a /dream ctx whose run is driven by a canned stop reason + diff."""
msg = InboundMessage(channel="cli", sender_id="u1", chat_id="direct", content="/dream")
store = _FakeStore(
_FakeGit(initialized=initialized),
last_dream_cursor=5,
dream_prompt_result=("dream prompt", 42),
content_diff=content_diff,
)
async def process_direct(*args, **kwargs):
return OutboundMessage(
channel="cli",
chat_id="direct",
content="done",
metadata={"_stop_reason": stop_reason},
)
bus = _FakeBus()
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
loop = SimpleNamespace(
bus=bus,
context=SimpleNamespace(memory=store, timezone="UTC"),
sessions=SimpleNamespace(sessions_dir=sessions_dir),
process_direct=process_direct,
)
ctx = CommandContext(msg=msg, session=None, key=msg.session_key, raw="/dream", args="", loop=loop)
return ctx, store
@pytest.mark.asyncio
async def test_dream_advances_cursor_when_diff_nonempty(tmp_path) -> None:
"""A real file delta => productive run => cursor advances (Tier 3)."""
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="SOUL.md: +1 -0")
await cmd_dream(ctx)
await asyncio.sleep(0)
assert store._last_dream_cursor == 42
@pytest.mark.asyncio
async def test_dream_keeps_cursor_on_completed_noop(tmp_path) -> None:
"""Completed run with no file changes must NOT advance the cursor, so the
history batch is reconsidered next run instead of silently swallowed."""
ctx, store = _build_runnable_dream(tmp_path, initialized=True, content_diff="")
await cmd_dream(ctx)
await asyncio.sleep(0)
assert store._last_dream_cursor == 5 # unchanged
@pytest.mark.asyncio
async def test_dream_non_git_falls_back_to_completion_gate(tmp_path) -> None:
"""Without git there is no diff signal; productivity falls back to the
completion check so non-git workspaces keep working."""
ctx, store = _build_runnable_dream(
tmp_path, initialized=False, content_diff="", stop_reason="completed",
)
await cmd_dream(ctx)
await asyncio.sleep(0)
assert store._last_dream_cursor == 42 # advanced via completion fallback
@pytest.mark.asyncio
async def test_dream_log_latest_is_more_user_friendly() -> None:
commit = CommitInfo(sha="abcd1234", message="dream: 2026-04-04, 2 change(s)", timestamp="2026-04-04 12:00")
diff = (
"diff --git a/SOUL.md b/SOUL.md\n"
"--- a/SOUL.md\n"
"+++ b/SOUL.md\n"
"@@ -1 +1 @@\n"
"-old\n"
"+new\n"
)
git = _FakeGit(commits=[commit], diff_map={commit.sha: (commit, diff)})
out = await cmd_dream_log(_make_ctx("/dream-log", git))
assert "## Dream Update" in out.content
assert "Here is the latest Dream memory change." in out.content
assert "- Commit: `abcd1234`" in out.content
assert "- Changed files: `SOUL.md`" in out.content
assert "Use `/dream-restore abcd1234` to undo this change." in out.content
assert "```diff" in out.content
@pytest.mark.asyncio
async def test_dream_log_missing_commit_guides_user() -> None:
git = _FakeGit(diff_map={})
out = await cmd_dream_log(_make_ctx("/dream-log deadbeef", git, args="deadbeef"))
assert "Couldn't find Dream change `deadbeef`." in out.content
assert "Use `/dream-restore` to list recent versions" in out.content
@pytest.mark.asyncio
async def test_dream_log_before_first_run_is_clear() -> None:
git = _FakeGit(initialized=False)
out = await cmd_dream_log(_make_ctx("/dream-log", git, last_dream_cursor=0))
assert "Dream has not run yet." in out.content
assert "Run `/dream`" in out.content
assert "/dream-prompt" in out.content
@pytest.mark.asyncio
async def test_dream_log_without_saved_versions_mentions_prompt_command() -> None:
git = _FakeGit(initialized=True, commits=[])
out = await cmd_dream_log(_make_ctx("/dream-log", git))
assert "Dream memory has no saved versions yet." in out.content
assert "/dream-prompt" in out.content
@pytest.mark.asyncio
async def test_dream_prompt_reports_default_prompt(tmp_path) -> None:
out = await cmd_dream_prompt(_make_dream_prompt_ctx(tmp_path))
assert "Dream memory instructions: nanobot default" in out.content
assert "prompts/dream.md" in out.content
assert str(tmp_path) not in out.content
assert "/dream-prompt init" in out.content
@pytest.mark.asyncio
async def test_dream_prompt_init_copies_default_prompt(tmp_path) -> None:
ctx = _make_dream_prompt_ctx(tmp_path, "/dream-prompt init", "init")
out = await cmd_dream_prompt(ctx)
prompt_file = tmp_path / "prompts" / "dream.md"
assert "Created Dream memory instructions" in out.content
assert "prompts/dream.md" in out.content
assert str(tmp_path) not in out.content
assert "fully replaces nanobot's default Dream guide" in out.content
assert prompt_file.read_text(encoding="utf-8") == MemoryStore.default_dream_prompt() + "\n"
@pytest.mark.asyncio
async def test_dream_prompt_init_does_not_overwrite_existing_prompt(tmp_path) -> None:
prompt_file = tmp_path / "prompts" / "dream.md"
prompt_file.parent.mkdir()
prompt_file.write_text("custom", encoding="utf-8")
ctx = _make_dream_prompt_ctx(tmp_path, "/dream-prompt init", "init")
out = await cmd_dream_prompt(ctx)
assert "already exist" in out.content
assert "prompts/dream.md" in out.content
assert str(tmp_path) not in out.content
assert prompt_file.read_text(encoding="utf-8") == "custom"
@pytest.mark.asyncio
async def test_dream_prompt_init_recreates_empty_prompt(tmp_path) -> None:
prompt_file = tmp_path / "prompts" / "dream.md"
prompt_file.parent.mkdir()
prompt_file.write_text(" \n", encoding="utf-8")
ctx = _make_dream_prompt_ctx(tmp_path, "/dream-prompt init", "init")
out = await cmd_dream_prompt(ctx)
assert "Created Dream memory instructions" in out.content
assert prompt_file.read_text(encoding="utf-8") == MemoryStore.default_dream_prompt() + "\n"
def test_dream_prompt_command_in_help_and_palette() -> None:
palette = builtin_command_palette()
dream_prompt = next(item for item in palette if item["command"] == "/dream-prompt")
assert dream_prompt["arg_hint"] == "[init]"
assert dream_prompt["lifecycle"] == "side_channel"
assert dream_prompt["accepts_args"] is True
assert "/dream-prompt [init]" in build_help_text()
@pytest.mark.asyncio
async def test_dream_restore_lists_versions_with_next_steps() -> None:
commits = [
CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00"),
CommitInfo(sha="bbbb2222", message="dream: older", timestamp="2026-04-04 08:00"),
]
git = _FakeGit(commits=commits)
out = await cmd_dream_restore(_make_ctx("/dream-restore", git))
assert "## Dream Restore" in out.content
assert "Choose a Dream memory version to restore." in out.content
assert "`abcd1234` 2026-04-04 12:00 - dream: latest" in out.content
assert "Preview a version with `/dream-log <sha>`" in out.content
assert "Restore a version with `/dream-restore <sha>`." in out.content
@pytest.mark.asyncio
async def test_dream_restore_success_mentions_files_and_followup() -> None:
commit = CommitInfo(sha="abcd1234", message="dream: latest", timestamp="2026-04-04 12:00")
diff = (
"diff --git a/SOUL.md b/SOUL.md\n"
"--- a/SOUL.md\n"
"+++ b/SOUL.md\n"
"@@ -1 +1 @@\n"
"-old\n"
"+new\n"
"diff --git a/memory/MEMORY.md b/memory/MEMORY.md\n"
"--- a/memory/MEMORY.md\n"
"+++ b/memory/MEMORY.md\n"
"@@ -1 +1 @@\n"
"-old\n"
"+new\n"
)
git = _FakeGit(
diff_map={commit.sha: (commit, diff)},
revert_result="eeee9999",
)
out = await cmd_dream_restore(_make_ctx("/dream-restore abcd1234", git, args="abcd1234"))
assert "Restored Dream memory to the state before `abcd1234`." in out.content
assert "- New safety commit: `eeee9999`" in out.content
assert "- Restored files: `SOUL.md`, `memory/MEMORY.md`" in out.content
assert "Use `/dream-log eeee9999` to inspect the restore diff." in out.content