nanobot/tests/command/test_model_command.py
chengyongru d1a94dae8a
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>
2026-06-02 22:46:47 +08:00

192 lines
6.2 KiB
Python

from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.command.builtin import (
build_help_text,
builtin_command_palette,
cmd_goal,
cmd_model,
register_builtin_commands,
)
from nanobot.command.router import CommandContext, CommandRouter
from nanobot.config.schema import ModelPresetConfig
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
def _make_loop(tmp_path) -> AgentLoop:
return AgentLoop(
bus=MessageBus(),
provider=_provider("base-model", max_tokens=123),
workspace=tmp_path,
model="base-model",
context_window_tokens=1000,
model_presets={
"default": ModelPresetConfig(
model="base-model",
max_tokens=123,
context_window_tokens=1000,
),
"fast": ModelPresetConfig(
model="openai/gpt-4.1",
max_tokens=4096,
context_window_tokens=32_768,
),
},
)
def _ctx(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw)
return CommandContext(msg=msg, session=None, key=msg.session_key, raw=raw, args=args, loop=loop)
def _ctx_session(loop: AgentLoop, raw: str, args: str = "") -> CommandContext:
msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content=raw)
return CommandContext(
msg=msg, session=MagicMock(), key=msg.session_key, raw=raw, args=args, loop=loop,
)
@pytest.mark.asyncio
async def test_model_command_lists_current_and_available_presets(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_model(_ctx(loop, "/model"))
assert "Current model: `base-model`" in out.content
assert "Current preset: `default`" in out.content
assert "Available presets: `default`, `fast`" in out.content
assert "`fast`" in out.content
assert out.metadata == {"render_as": "text"}
@pytest.mark.asyncio
async def test_model_command_switches_preset(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_model(_ctx(loop, "/model fast", args="fast"))
assert "Switched model preset to `fast`." in out.content
assert "Model: `openai/gpt-4.1`" in out.content
assert loop.model_preset == "fast"
assert loop.model == "openai/gpt-4.1"
assert loop.subagents.model == "openai/gpt-4.1"
assert loop.consolidator.model == "openai/gpt-4.1"
@pytest.mark.asyncio
async def test_model_command_switches_back_to_default(tmp_path) -> None:
loop = _make_loop(tmp_path)
loop.set_model_preset("fast")
out = await cmd_model(_ctx(loop, "/model default", args="default"))
assert "Switched model preset to `default`." in out.content
assert loop.model_preset == "default"
assert loop.model == "base-model"
assert loop.context_window_tokens == 1000
@pytest.mark.asyncio
async def test_model_command_unknown_preset_keeps_old_state(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_model(_ctx(loop, "/model missing", args="missing"))
assert "Could not switch model preset" in out.content
assert "\"model_preset" not in out.content
assert "Available presets: `default`, `fast`" in out.content
assert loop.model_preset is None
assert loop.model == "base-model"
@pytest.mark.asyncio
async def test_model_command_does_not_depend_on_my_allow_set(tmp_path) -> None:
loop = _make_loop(tmp_path)
assert loop.tools_config.my.allow_set is False
await cmd_model(_ctx(loop, "/model fast", args="fast"))
assert loop.model_preset == "fast"
@pytest.mark.asyncio
async def test_model_command_registered_as_exact_and_prefix(tmp_path) -> None:
router = CommandRouter()
register_builtin_commands(router)
loop = _make_loop(tmp_path)
out = await router.dispatch(_ctx(loop, "/model fast"))
assert out is not None
assert "Switched model preset" in out.content
assert loop.model_preset == "fast"
def test_model_command_in_help_and_palette() -> None:
palette = builtin_command_palette()
assert any(item["command"] == "/model" and item["arg_hint"] == "[preset]" for item in palette)
assert "/model [preset]" in build_help_text()
@pytest.mark.asyncio
async def test_goal_command_shows_usage_without_args(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_goal(_ctx(loop, "/goal"))
assert out is not None
assert "Usage: /goal" in out.content
@pytest.mark.asyncio
async def test_goal_command_rejects_mid_turn_without_session(tmp_path) -> None:
loop = _make_loop(tmp_path)
out = await cmd_goal(_ctx(loop, "/goal do work", args="do work"))
assert out is not None
assert "/stop" in out.content
@pytest.mark.asyncio
async def test_goal_command_rewrites_to_agent_prompt(tmp_path) -> None:
loop = _make_loop(tmp_path)
ctx = _ctx_session(loop, "/goal audit the repo", args="audit the repo")
out = await cmd_goal(ctx)
assert out is None
assert "audit the repo" in ctx.msg.content
assert "long_task" in ctx.msg.content
assert ctx.msg.metadata.get("original_command") == "/goal"
assert ctx.msg.metadata.get("original_content") == "/goal audit the repo"
assert isinstance(ctx.msg.metadata.get("goal_started_at"), int | float)
@pytest.mark.asyncio
async def test_goal_command_registered_on_router(tmp_path) -> None:
router = CommandRouter()
register_builtin_commands(router)
loop = _make_loop(tmp_path)
ctx = _ctx_session(loop, "/goal ship it", args="ship it")
out = await router.dispatch(ctx)
assert out is None
assert "ship it" in ctx.msg.content
def test_goal_command_in_help_and_palette() -> None:
palette = builtin_command_palette()
assert any(item["command"] == "/goal" and item["arg_hint"] == "<goal>" for item in palette)
assert "/goal <goal>" in build_help_text()