fix(heartbeat): retain history after empty runs

This commit is contained in:
chengyongru
2026-07-15 01:19:48 +08:00
committed by chengyongru
parent dcb37259fa
commit 63a6d5d07d
2 changed files with 108 additions and 5 deletions
+5 -5
View File
@@ -1860,16 +1860,16 @@ def _run_gateway(
if isinstance(message_tool, MessageTool) and suppress_token is not None:
message_tool.reset_suppress_delivery(suppress_token)
if not resp or not resp.content:
return
response = resp.content
# Keep a small tail of heartbeat history so the loop stays bounded.
session = agent.sessions.get_or_create("heartbeat")
session.retain_recent_legal_suffix(hb_cfg.keep_recent_messages)
agent.sessions.save(session)
if not resp or not resp.content:
return
response = resp.content
evaluator_prompt = resolve_evaluator_prompt(config.workspace_path)
# Fail closed: stay silent on evaluator failure instead of notifying.
+103
View File
@@ -1705,6 +1705,109 @@ def _patch_cli_command_runtime(
monkeypatch.setattr("nanobot.config.paths.get_cron_dir", get_cron_dir)
def test_heartbeat_empty_response_still_retains_recent_messages(
monkeypatch, tmp_path: Path,
) -> None:
config_file = _write_instance_config(tmp_path)
config = Config()
config.agents.defaults.workspace = str(tmp_path / "workspace")
config.agents.defaults.dream.enabled = True
config.workspace_path.mkdir(parents=True)
(config.workspace_path / "HEARTBEAT.md").write_text(
"## Active Tasks\n\n- Check repository health\n",
encoding="utf-8",
)
provider = _fake_provider()
bus = MagicMock()
bus.publish_outbound = AsyncMock()
seen: dict[str, object] = {}
class _FakeSession:
def retain_recent_legal_suffix(self, limit: int) -> None:
seen["retained_limit"] = limit
class _FakeSessionManager:
def __init__(self, _workspace: Path) -> None:
self.session = _FakeSession()
seen["heartbeat_session"] = self.session
def get_or_create(self, key: str) -> _FakeSession:
seen["session_key"] = key
return self.session
def save(self, session: _FakeSession) -> None:
seen["saved_session"] = session
def list_sessions(self) -> list[dict[str, str]]:
return [{"key": "telegram:u1"}]
class _FakeCron:
def __init__(self, _store_path: Path) -> None:
self.on_job = None
seen["cron"] = self
def status(self) -> dict[str, int]:
return {"jobs": 0}
def register_system_job(self, _job: CronJob) -> None:
raise _StopGatewayError("stop")
class _FakeAgentLoop:
@classmethod
def from_config(cls, config, bus=None, **extra):
return cls(**extra)
def __init__(self, *args, **kwargs) -> None:
self.model = "test-model"
self.provider = kwargs.get("provider", object())
self.sessions = kwargs["session_manager"]
self.tools = {}
async def process_direct(self, *_args, **_kwargs):
return SimpleNamespace(content="")
async def close_mcp(self) -> None:
return None
async def run(self) -> None:
return None
def stop(self) -> None:
return None
class _FakeChannelManager:
def __init__(self, *_args, **_kwargs) -> None:
self.enabled_channels = ["telegram"]
async def _unexpected_evaluator(*_args, **_kwargs) -> bool:
raise AssertionError("empty heartbeat response must not be evaluated")
_patch_cli_command_runtime(
monkeypatch,
config,
make_provider=lambda _config: provider,
message_bus=lambda: bus,
session_manager=_FakeSessionManager,
cron_service=_FakeCron,
)
monkeypatch.setattr("nanobot.cli.commands.AgentLoop", _FakeAgentLoop)
monkeypatch.setattr("nanobot.channels.manager.ChannelManager", _FakeChannelManager)
monkeypatch.setattr("nanobot.cli.commands.read_webui_sidebar_state", lambda: {})
monkeypatch.setattr("nanobot.cli.commands.evaluate_response", _unexpected_evaluator)
result = runner.invoke(app, ["gateway", "--config", str(config_file)])
assert isinstance(result.exception, _StopGatewayError)
cron = seen["cron"]
response = asyncio.run(cron.on_job(CronJob(id="heartbeat", name="heartbeat")))
assert response is None
assert seen["session_key"] == "heartbeat"
assert seen["retained_limit"] == config.gateway.heartbeat.keep_recent_messages
assert seen["saved_session"] is seen["heartbeat_session"]
def test_webui_yes_creates_config_and_enables_local_websocket(
monkeypatch,
tmp_path: Path,