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.
This commit is contained in:
chengyongru 2026-05-14 15:04:41 +08:00 committed by Xubin Ren
parent 8b724d510e
commit 26665823e3
3 changed files with 51 additions and 0 deletions

View File

@ -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"

View File

@ -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):

View File

@ -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."""