From 26665823e34bb538f375d182a3757af2f5a1b49b Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 14 May 2026 15:04:41 +0800 Subject: [PATCH] fix(agent): persist shortcut commands without polluting LLM context Shortcut commands (e.g. /help, /pairing) skip BUILD and SAVE states, so their turns were never persisted to the session. This caused WebUI chats to appear empty after _turn_end because history hydration reads from the session file. Fix by persisting the user message and assistant response inside _state_command, but tag them with _command=True so Session.get_history filters them out of LLM context. /new is excluded because it intentionally clears the session. - AgentLoop._persist_user_message_early now accepts **kwargs so _state_command can pass _command=True for the user turn. - Session.get_history skips messages with _command=True. --- nanobot/agent/loop.py | 15 ++++++++++++++ nanobot/session/manager.py | 2 ++ tests/agent/test_auto_compact.py | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 9bfce39fb..e90b30387 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -564,6 +564,7 @@ class AgentLoop: self, msg: InboundMessage, session: Session, + **kwargs: Any, ) -> bool: """Persist the triggering user message before the turn starts. @@ -573,6 +574,7 @@ class AgentLoop: has_text = isinstance(msg.content, str) and msg.content.strip() if has_text or media_paths: extra: dict[str, Any] = {"media": list(media_paths)} if media_paths else {} + extra.update(kwargs) text = msg.content if isinstance(msg.content, str) else "" session.add_message("user", text, **extra) self._mark_pending_user_turn(session) @@ -1268,6 +1270,19 @@ class AgentLoop: result = await self.commands.dispatch(cmd_ctx) if result is not None: ctx.outbound = result + # Shortcut commands skip BUILD and SAVE, so we must persist the + # turn here so WebUI history hydration after _turn_end sees the + # message. Mark messages with _command so get_history can filter + # them out of LLM context. /new is excluded because it + # intentionally clears the session. + if raw.lower() != "/new": + ctx.user_persisted_early = self._persist_user_message_early( + ctx.msg, ctx.session, _command=True + ) + ctx.session.add_message( + "assistant", result.content, _command=True + ) + self.sessions.save(ctx.session) return "shortcut" return "dispatch" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 188911435..739007cbd 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -139,6 +139,8 @@ class Session: out: list[dict[str, Any]] = [] for message in sliced: + if message.get("_command"): + continue content = message.get("content", "") role = message.get("role") if role == "assistant" and isinstance(content, str): diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index 41d79f85b..5d4946b02 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -418,6 +418,40 @@ class TestAutoCompactIdleDetection: assert len(session_after.messages) == 0 await loop.close_mcp() + @pytest.mark.asyncio + async def test_shortcut_command_persisted_with_command_flag(self, tmp_path): + """Shortcut commands (e.g. /help) are persisted so WebUI can show them, + but tagged with _command so they don't leak into LLM context.""" + loop = _make_loop(tmp_path) + msg = InboundMessage(channel="cli", sender_id="user", chat_id="test", content="/help") + response = await loop._process_message(msg) + + assert response is not None + session_after = loop.sessions.get_or_create("cli:test") + assert len(session_after.messages) == 2 + assert session_after.messages[0]["role"] == "user" + assert session_after.messages[0]["content"] == "/help" + assert session_after.messages[0].get("_command") is True + assert session_after.messages[1]["role"] == "assistant" + assert session_after.messages[1].get("_command") is True + await loop.close_mcp() + + @pytest.mark.asyncio + async def test_shortcut_command_excluded_from_get_history(self, tmp_path): + """Messages marked _command are invisible to get_history (LLM context).""" + loop = _make_loop(tmp_path) + session = loop.sessions.get_or_create("cli:test") + session.add_message("user", "real question") + session.add_message("assistant", "real answer") + session.add_message("user", "/help", _command=True) + session.add_message("assistant", "help text", _command=True) + + history = session.get_history() + assert len(history) == 2 + assert all(m["content"] != "/help" for m in history) + assert all(m["content"] != "help text" for m in history) + await loop.close_mcp() + class TestAutoCompactSystemMessages: """Test that auto-new also works for system messages."""